fix(portal): prefix unused totalPending param with underscore
Silence @typescript-eslint/no-unused-vars for totalPending in PaymentModal — it is accepted as a prop for API compatibility but the modal computes selectedTotal from selected invoices instead. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -111,6 +111,8 @@ jobs:
|
||||
name: Build & Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, e2e]
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -244,8 +246,8 @@ jobs:
|
||||
kubectl set image deployment/web web=ghcr.io/groombook/web:$TAG -n groombook-dev
|
||||
|
||||
# Wait for rollout
|
||||
kubectl rollout status deployment/api -n groombook-dev --timeout=120s
|
||||
kubectl rollout status deployment/web -n groombook-dev --timeout=120s
|
||||
kubectl rollout status deployment/api -n groombook-dev --timeout=300s
|
||||
kubectl rollout status deployment/web -n groombook-dev --timeout=300s
|
||||
|
||||
echo "Deployment complete."
|
||||
|
||||
@@ -268,3 +270,71 @@ jobs:
|
||||
'Ready for UAT validation.'
|
||||
].join('\n')
|
||||
});
|
||||
|
||||
cd:
|
||||
name: Update Infra Image Tags
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Generate infra repo token
|
||||
id: infra-token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ vars.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clone groombook/infra
|
||||
run: |
|
||||
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
||||
|
||||
- name: Install yq
|
||||
run: |
|
||||
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 dev overlay image tags
|
||||
env:
|
||||
TAG: ${{ needs.docker.outputs.tag }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||
fi
|
||||
echo "Updating dev overlay image tags to: $TAG"
|
||||
cd /tmp/infra
|
||||
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
|
||||
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"
|
||||
git -C /tmp/infra diff --stat
|
||||
|
||||
- name: Create PR on groombook/infra
|
||||
env:
|
||||
TAG: ${{ needs.docker.outputs.tag }}
|
||||
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||
fi
|
||||
|
||||
cd /tmp/infra
|
||||
git config user.name "groombook-engineer[bot]"
|
||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||
git checkout -b "chore/update-image-tags-${TAG}"
|
||||
git add apps/groombook/overlays/dev/
|
||||
git commit -m "chore: update image tags to ${TAG}"
|
||||
|
||||
git push -u origin "chore/update-image-tags-${TAG}"
|
||||
|
||||
# Create PR with auto-merge
|
||||
PR_URL=$(gh pr create \
|
||||
--repo groombook/infra \
|
||||
--base main \
|
||||
--head "chore/update-image-tags-${TAG}" \
|
||||
--title "chore: deploy ${TAG} to dev" \
|
||||
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||
gh pr merge "$PR_URL" --auto --merge
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Release Helm Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'charts/**'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout groombook
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout groombook.github.io
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: groombook/groombook.github.io
|
||||
path: gh-pages
|
||||
token: ${{ secrets.CHART_REPO_TOKEN }}
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
|
||||
- name: Update Helm dependencies
|
||||
run: helm dependency update charts/groombook
|
||||
|
||||
- name: Package chart
|
||||
run: |
|
||||
mkdir -p gh-pages/charts
|
||||
helm package charts/groombook -d gh-pages/charts
|
||||
|
||||
- name: Update repo index
|
||||
run: |
|
||||
if [ -f gh-pages/charts/index.yaml ]; then
|
||||
helm repo index gh-pages/charts --merge gh-pages/charts/index.yaml --url https://groombook.github.io/charts
|
||||
else
|
||||
helm repo index gh-pages/charts --url https://groombook.github.io/charts
|
||||
fi
|
||||
|
||||
- name: Push to groombook.github.io
|
||||
run: |
|
||||
cd gh-pages
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add charts/
|
||||
git diff --staged --quiet && echo 'No chart changes' && exit 0
|
||||
git commit -m "Update Helm chart repository"
|
||||
git push
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Promote to Production
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Image tag to promote (e.g. 2026.03.28-f1b85bf)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
name: Promote to Production
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Generate infra repo token
|
||||
id: infra-token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ vars.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clone groombook/infra
|
||||
run: |
|
||||
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
||||
|
||||
- name: Install yq
|
||||
run: |
|
||||
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
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
cd /tmp/infra
|
||||
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
|
||||
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"
|
||||
git -C /tmp/infra diff --stat
|
||||
|
||||
- name: Create PR on groombook/infra
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
||||
run: |
|
||||
cd /tmp/infra
|
||||
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 commit -m "release: promote ${TAG} to production"
|
||||
git push -u origin "release/promote-prod-${TAG}"
|
||||
gh pr create \
|
||||
--repo groombook/infra \
|
||||
--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"
|
||||
@@ -0,0 +1,662 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -214,4 +214,4 @@ All PRs require CI to pass before merge. See [CONTRIBUTING.md](./CONTRIBUTING.md
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
AGPL-3.0
|
||||
|
||||
@@ -12,18 +12,17 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.800.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.800.0",
|
||||
"@groombook/db": "workspace:*",
|
||||
"@groombook/types": "workspace:*",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"better-auth": "^1.5.6",
|
||||
"hono": "^4.6.17",
|
||||
"jose": "^5.9.6",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"openid-client": "^6.1.7",
|
||||
"zod": "^3.24.1",
|
||||
"@aws-sdk/client-s3": "^3.800.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.800.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
@@ -35,5 +34,6 @@
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import type { StaffRow } from "../middleware/rbac.js";
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { JwtPayload } from "../middleware/auth.js";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
import { buildStaff } from "@groombook/db/factories";
|
||||
|
||||
@@ -167,7 +166,7 @@ function createApp(
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403);
|
||||
}
|
||||
c.set("jwtPayload", { sub: staffRow.oidcSub } as JwtPayload);
|
||||
c.set("jwtPayload", { sub: staffRow.oidcSub } as { sub: string; email?: string; name?: string });
|
||||
c.set("staff", staffRow as unknown as StaffRow);
|
||||
await next();
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
|
||||
@@ -8,7 +8,9 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: "ba-user-manager",
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
@@ -21,6 +23,7 @@ const RECEPTIONIST: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-receptionist-id",
|
||||
oidcSub: "oidc-receptionist-sub",
|
||||
userId: "ba-user-receptionist",
|
||||
role: "receptionist",
|
||||
name: "Receptionist Rita",
|
||||
email: "receptionist@example.com",
|
||||
@@ -30,6 +33,7 @@ const GROOMER: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-groomer-id",
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
userId: "ba-user-groomer",
|
||||
role: "groomer",
|
||||
name: "Groomer Gary",
|
||||
email: "groomer@example.com",
|
||||
@@ -89,7 +93,7 @@ function buildApp(
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffLookupResult?.oidcSub ?? "unknown-sub" });
|
||||
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
|
||||
await next();
|
||||
});
|
||||
app.use("*", middleware);
|
||||
@@ -106,7 +110,7 @@ function buildWithStaff(
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffRow.oidcSub ?? "" });
|
||||
c.set("jwtPayload", { sub: staffRow.userId ?? "" });
|
||||
c.set("staff", staffRow);
|
||||
await next();
|
||||
});
|
||||
@@ -165,7 +169,7 @@ describe("resolveStaffMiddleware", () => {
|
||||
});
|
||||
|
||||
const res = await app.request("/test", {
|
||||
headers: { "X-Dev-User-Id": GROOMER.oidcSub! },
|
||||
headers: { "X-Dev-User-Id": GROOMER.id },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedStaff!.role).toBe("groomer");
|
||||
|
||||
+31
-4
@@ -2,6 +2,7 @@ import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "hono/logger";
|
||||
import { cors } from "hono/cors";
|
||||
import { auth } from "./lib/auth.js";
|
||||
import { clientsRouter } from "./routes/clients.js";
|
||||
import { petsRouter } from "./routes/pets.js";
|
||||
import { servicesRouter } from "./routes/services.js";
|
||||
@@ -18,9 +19,10 @@ import { impersonationRouter } from "./routes/impersonation.js";
|
||||
import { settingsRouter } from "./routes/settings.js";
|
||||
import { searchRouter } from "./routes/search.js";
|
||||
import { calendarRouter } from "./routes/calendar.js";
|
||||
import { getDb, businessSettings } from "@groombook/db";
|
||||
import { setupRouter } from "./routes/setup.js";
|
||||
import { getDb, businessSettings, eq, staff } from "@groombook/db";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
|
||||
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
|
||||
import { devRouter } from "./routes/dev.js";
|
||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||
import { startReminderScheduler } from "./services/reminders.js";
|
||||
@@ -65,15 +67,37 @@ app.get("/api/branding", async (c) => {
|
||||
|
||||
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
||||
app.route("/api/calendar", calendarRouter);
|
||||
|
||||
// Public setup status — no auth required, must be registered before auth middleware
|
||||
app.get("/api/setup/status", async (c) => {
|
||||
const db = getDb();
|
||||
const [superUser] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
return c.json({ needsSetup: !superUser });
|
||||
});
|
||||
|
||||
// Protected API routes
|
||||
const api = app.basePath("/api");
|
||||
api.use("*", authMiddleware);
|
||||
api.use("*", resolveStaffMiddleware);
|
||||
|
||||
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
||||
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
||||
const authRouter = new Hono();
|
||||
authRouter.all("/*", (c) => auth.handler(c.req.raw));
|
||||
api.route("/auth", authRouter);
|
||||
|
||||
// ── Role guards ────────────────────────────────────────────────────────────────
|
||||
// Manager-only: staff, admin settings, reports, invoices, impersonation
|
||||
api.use("/staff/*", requireRole("manager"));
|
||||
// Manager-only: admin settings, reports, invoices, impersonation
|
||||
// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE
|
||||
api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
|
||||
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
|
||||
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||
api.use("/admin/*", requireRole("manager"));
|
||||
api.use("/admin/settings/*", requireSuperUser());
|
||||
api.use("/reports/*", requireRole("manager"));
|
||||
api.use("/invoices/*", requireRole("manager"));
|
||||
api.use("/impersonation/*", requireRole("manager"));
|
||||
@@ -113,6 +137,9 @@ api.on(
|
||||
);
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Setup: POST /api/setup (authenticated) — requires staff context from auth middleware
|
||||
api.route("/setup", setupRouter);
|
||||
|
||||
api.route("/clients", clientsRouter);
|
||||
api.route("/pets", petsRouter);
|
||||
api.route("/services", servicesRouter);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { getDb } from "@groombook/db";
|
||||
|
||||
const OIDC_ISSUER = process.env.OIDC_ISSUER;
|
||||
const OIDC_INTERNAL_BASE = process.env.OIDC_INTERNAL_BASE; // e.g. http://authentik-server.auth.svc.cluster.local
|
||||
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID;
|
||||
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
|
||||
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
||||
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
||||
|
||||
if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") {
|
||||
throw new Error(
|
||||
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
|
||||
);
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(getDb(), {
|
||||
provider: "pg",
|
||||
}),
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "authentik",
|
||||
clientId: OIDC_CLIENT_ID ?? "",
|
||||
clientSecret: OIDC_CLIENT_SECRET ?? "",
|
||||
// When OIDC_INTERNAL_BASE is set, use explicit URLs to avoid hairpin NAT:
|
||||
// - authorizationUrl: external (browser redirect, no server-side fetch)
|
||||
// - tokenUrl/userInfoUrl: internal (server-to-server, avoids hairpin)
|
||||
// When not set, fall back to discoveryUrl for local dev.
|
||||
...(OIDC_INTERNAL_BASE
|
||||
? {
|
||||
authorizationUrl: `${new URL(OIDC_ISSUER!).origin}/application/o/authorize/`,
|
||||
tokenUrl: `${OIDC_INTERNAL_BASE}/application/o/token/`,
|
||||
userInfoUrl: `${OIDC_INTERNAL_BASE}/application/o/userinfo/`,
|
||||
}
|
||||
: {
|
||||
discoveryUrl: OIDC_ISSUER
|
||||
? `${OIDC_ISSUER}/.well-known/openid-configuration`
|
||||
: undefined,
|
||||
}),
|
||||
scopes: ["openid", "profile", "email"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // 1 day
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
},
|
||||
},
|
||||
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||
});
|
||||
@@ -1,34 +1,18 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||
import { auth } from "../lib/auth.js";
|
||||
|
||||
// Authentik OIDC configuration — loaded from env at startup
|
||||
const OIDC_ISSUER = process.env.OIDC_ISSUER;
|
||||
const OIDC_AUDIENCE = process.env.OIDC_AUDIENCE;
|
||||
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
function getJwks() {
|
||||
if (!OIDC_ISSUER) throw new Error("OIDC_ISSUER is not set");
|
||||
if (!jwks) {
|
||||
jwks = createRemoteJWKSet(
|
||||
new URL(`${OIDC_ISSUER}/application/o/groombook/jwks/`)
|
||||
);
|
||||
}
|
||||
return jwks;
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// Guard: refuse to start with AUTH_DISABLED in production (fixes #22).
|
||||
// Guard: refuse to start with AUTH_DISABLED in production.
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.error(
|
||||
"[FATAL] AUTH_DISABLED=true is not allowed in production. " +
|
||||
"Remove AUTH_DISABLED from your environment and configure OIDC_ISSUER."
|
||||
"Remove AUTH_DISABLED from your environment and configure Better-Auth."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -39,30 +23,33 @@ if (process.env.AUTH_DISABLED === "true") {
|
||||
}
|
||||
|
||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
const devUserId = c.req.header("X-Dev-User-Id");
|
||||
const sub = devUserId ?? "dev-user";
|
||||
c.set("jwtPayload", { sub } as JwtPayload);
|
||||
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
|
||||
if (c.req.path.startsWith("/api/auth/")) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const authorization = c.req.header("Authorization");
|
||||
if (!authorization?.startsWith("Bearer ")) {
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
const devUserId = c.req.header("X-Dev-User-Id");
|
||||
const sub = devUserId ?? "dev-user";
|
||||
c.set("jwtPayload", { sub } as { sub: string });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const token = authorization.slice(7);
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwks(), {
|
||||
issuer: OIDC_ISSUER,
|
||||
audience: OIDC_AUDIENCE,
|
||||
});
|
||||
|
||||
c.set("jwtPayload", payload as JwtPayload);
|
||||
await next();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid or expired token" }, 401);
|
||||
}
|
||||
// Set jwtPayload with sub = Better-Auth user ID for backward compat with resolveStaffMiddleware
|
||||
c.set("jwtPayload", {
|
||||
sub: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
});
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { eq, getDb, staff } from "@groombook/db";
|
||||
import type { JwtPayload } from "./auth.js";
|
||||
|
||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
|
||||
export interface AppEnv {
|
||||
Variables: {
|
||||
jwtPayload: JwtPayload;
|
||||
jwtPayload: { sub: string; email?: string; name?: string };
|
||||
staff: StaffRow;
|
||||
};
|
||||
}
|
||||
@@ -16,13 +15,19 @@ export interface AppEnv {
|
||||
* Resolves the authenticated staff record from the DB and stores it in context.
|
||||
* Must be applied after authMiddleware on all protected routes.
|
||||
*
|
||||
* Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (treated
|
||||
* as oidcSub), or falls back to the first manager in the DB.
|
||||
* Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (Better-Auth
|
||||
* user ID), or falls back to the first manager in the DB.
|
||||
*/
|
||||
export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
c,
|
||||
next
|
||||
) => {
|
||||
// Better-Auth's own routes handle their own auth — skip staff resolution
|
||||
if (c.req.path.startsWith("/api/auth/")) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
@@ -37,38 +42,59 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
if (!manager) {
|
||||
return c.json({ error: "Forbidden: no staff records found" }, 403);
|
||||
}
|
||||
c.set("staff", manager);
|
||||
c.set("staff", { ...manager, isSuperUser: true });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Treat X-Dev-User-Id as the oidcSub
|
||||
// Treat X-Dev-User-Id as the Better-Auth user ID first
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.oidcSub, devUserId));
|
||||
if (!row) {
|
||||
.where(eq(staff.userId, devUserId));
|
||||
if (row) {
|
||||
c.set("staff", { ...row, isSuperUser: true });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Fallback: if userId is null, treat X-Dev-User-Id as staff.id (dev login
|
||||
// may send the primary key for staff records that predate the userId field)
|
||||
const [fallbackRow] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, devUserId));
|
||||
if (!fallbackRow) {
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for X-Dev-User-Id" },
|
||||
403
|
||||
);
|
||||
}
|
||||
c.set("staff", row);
|
||||
c.set("staff", { ...fallbackRow, isSuperUser: true });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = c.get("jwtPayload");
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.userId, jwt.sub));
|
||||
if (row) {
|
||||
c.set("staff", row);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Fallback: staff records that predate the userId field may still have oidcSub
|
||||
const [fallbackRow] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.oidcSub, jwt.sub));
|
||||
if (!row) {
|
||||
if (!fallbackRow) {
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
);
|
||||
}
|
||||
c.set("staff", row);
|
||||
c.set("staff", fallbackRow);
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -99,3 +125,58 @@ export function requireRole(
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that allows access if the staff member has any of the allowed roles OR is a super user.
|
||||
* Use for routes where managers OR super-users should have access.
|
||||
*
|
||||
* @example
|
||||
* api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||
*/
|
||||
export function requireRoleOrSuperUser(
|
||||
...allowedRoles: StaffRole[]
|
||||
): MiddlewareHandler<AppEnv> {
|
||||
return async (c, next) => {
|
||||
const staffRow = c.get("staff");
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
|
||||
}
|
||||
const hasAllowedRole = (allowedRoles as string[]).includes(staffRow.role);
|
||||
if (hasAllowedRole || staffRow.isSuperUser) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
error: staffRow.isSuperUser
|
||||
? `Forbidden: role '${staffRow.role}' is not permitted`
|
||||
: "Forbidden: super user privileges required",
|
||||
},
|
||||
403
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that enforces the staff member is a super user.
|
||||
* Must be applied after resolveStaffMiddleware and (typically) after requireRole.
|
||||
*
|
||||
* @example
|
||||
* api.use("/staff/*", requireRole("manager"));
|
||||
* api.use("/staff/*", requireSuperUser());
|
||||
*/
|
||||
export function requireSuperUser(): MiddlewareHandler<AppEnv> {
|
||||
return async (c, next) => {
|
||||
const staffRow = c.get("staff");
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
|
||||
}
|
||||
if (!staffRow.isSuperUser) {
|
||||
return c.json(
|
||||
{ error: "Forbidden: super user privileges required" },
|
||||
403
|
||||
);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ devRouter.get("/users", async (c) => {
|
||||
const staffList = await db
|
||||
.select({
|
||||
id: staff.id,
|
||||
userId: staff.userId,
|
||||
name: staff.name,
|
||||
email: staff.email,
|
||||
role: staff.role,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
||||
|
||||
export const groomingLogsRouter = new Hono();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
|
||||
+137
-57
@@ -1,11 +1,135 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { and, eq, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, inArray } from "@groombook/db";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const portalRouter = new Hono<AppEnv>();
|
||||
|
||||
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||
|
||||
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
|
||||
if (!sessionId) return null;
|
||||
const db = getDb();
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||
.limit(1);
|
||||
if (!session || session.expiresAt <= new Date()) return null;
|
||||
return session.clientId;
|
||||
}
|
||||
|
||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
portalRouter.get("/me", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||
if (!client) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
||||
});
|
||||
|
||||
portalRouter.get("/services", async (c) => {
|
||||
const db = getDb();
|
||||
const allServices = await db.select().from(services).where(eq(services.active, true));
|
||||
return c.json(allServices.map(s => ({ id: s.id, name: s.name, description: s.description, basePriceCents: s.basePriceCents, durationMinutes: s.durationMinutes })));
|
||||
});
|
||||
|
||||
portalRouter.get("/appointments", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const now = new Date();
|
||||
const allAppts = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
status: appointments.status,
|
||||
confirmationStatus: appointments.confirmationStatus,
|
||||
customerNotes: appointments.customerNotes,
|
||||
notes: appointments.notes,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(eq(appointments.clientId, clientId))
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
|
||||
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
|
||||
|
||||
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
|
||||
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
|
||||
|
||||
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
|
||||
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
|
||||
|
||||
const appts = allAppts.map(a => ({
|
||||
id: a.id,
|
||||
startTime: a.startTime,
|
||||
endTime: a.endTime,
|
||||
status: a.status,
|
||||
confirmationStatus: a.confirmationStatus,
|
||||
customerNotes: a.customerNotes,
|
||||
notes: a.notes,
|
||||
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
|
||||
service: a.serviceId ? { id: a.serviceId } : null,
|
||||
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||
}));
|
||||
|
||||
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
|
||||
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
|
||||
|
||||
return c.json({ upcoming, past });
|
||||
});
|
||||
|
||||
portalRouter.get("/pets", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
||||
});
|
||||
|
||||
portalRouter.get("/invoices", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||
const invoiceIds = clientInvoices.map(i => i.id);
|
||||
const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(inArray(invoiceLineItems.invoiceId, invoiceIds)) : [];
|
||||
|
||||
const itemsByInvoice: Record<string, typeof lineItems> = {};
|
||||
for (const li of lineItems) {
|
||||
if (!itemsByInvoice[li.invoiceId]) itemsByInvoice[li.invoiceId] = [];
|
||||
itemsByInvoice[li.invoiceId]!.push(li);
|
||||
}
|
||||
|
||||
return c.json(clientInvoices.map(inv => ({
|
||||
id: inv.id,
|
||||
status: inv.status,
|
||||
totalCents: inv.totalCents,
|
||||
createdAt: inv.createdAt,
|
||||
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
||||
})));
|
||||
});
|
||||
|
||||
// ─── Appointment action routes ────────────────────────────────────────────────
|
||||
|
||||
const customerNotesSchema = z.object({
|
||||
// .min(1) prevents empty strings — clearing notes is not a supported use case
|
||||
customerNotes: z.string().min(1).max(500),
|
||||
@@ -20,27 +144,11 @@ portalRouter.patch(
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
if (!sessionId) {
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const authClientId = session.clientId;
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
@@ -51,7 +159,7 @@ portalRouter.patch(
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (appt.clientId !== authClientId) {
|
||||
if (appt.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -84,22 +192,8 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
@@ -113,7 +207,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (appt.clientId !== session.clientId) {
|
||||
if (appt.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -152,22 +246,8 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
@@ -181,7 +261,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (appt.clientId !== session.clientId) {
|
||||
if (appt.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -212,7 +292,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Client-facing waitlist routes ───────────────────────────────────────────
|
||||
// ─── Client-facing waitlist routes ────────────────────────────────────────────
|
||||
|
||||
const createWaitlistEntrySchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
@@ -366,4 +446,4 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
.returning();
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, services } from "@groombook/db";
|
||||
|
||||
export const servicesRouter = new Hono();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, businessSettings } from "@groombook/db";
|
||||
|
||||
export const settingsRouter = new Hono();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, staff, businessSettings } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const setupRouter = new Hono<AppEnv>();
|
||||
|
||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||
setupRouter.get("/status", async (c) => {
|
||||
const db = getDb();
|
||||
|
||||
// Check if any super user exists
|
||||
const [superUser] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
|
||||
return c.json({ needsSetup: !superUser });
|
||||
});
|
||||
|
||||
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
|
||||
setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const currentStaff = c.get("staff");
|
||||
|
||||
// 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
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.for("update")
|
||||
.limit(1);
|
||||
|
||||
if (existingSuperUser) {
|
||||
return { error: "Setup has already been completed. A super user already exists.", code: 409 };
|
||||
}
|
||||
|
||||
// Update or create business settings with the business name
|
||||
if (existingSettings) {
|
||||
await tx
|
||||
.update(businessSettings)
|
||||
.set({ businessName: body.businessName, updatedAt: new Date() })
|
||||
.where(eq(businessSettings.id, existingSettings.id));
|
||||
} else {
|
||||
await tx.insert(businessSettings).values({ businessName: body.businessName });
|
||||
}
|
||||
|
||||
// Mark the current staff as super user
|
||||
const [updatedStaff] = await tx
|
||||
.update(staff)
|
||||
.set({ isSuperUser: true, updatedAt: new Date() })
|
||||
.where(eq(staff.id, currentStaff.id))
|
||||
.returning();
|
||||
|
||||
return { staff: updatedStaff };
|
||||
});
|
||||
|
||||
if ("error" in result) {
|
||||
return c.json({ error: result.error }, 409);
|
||||
}
|
||||
|
||||
return c.json({ ok: true, staff: result.staff }, 201);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, getDb, ne, staff, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ test("clients page shows client list", async ({ page }) => {
|
||||
|
||||
test("clients page shows search input", async ({ page }) => {
|
||||
await page.goto("/admin/clients");
|
||||
await expect(page.getByPlaceholder(/search/i)).toBeVisible();
|
||||
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking a client shows their details", async ({ page }) => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Custom test fixture that bypasses the dev login redirect for E2E tests.
|
||||
* Custom test fixture that bypasses auth for E2E tests.
|
||||
*
|
||||
* When AUTH_DISABLED=true, the app fetches /api/dev/config and redirects to
|
||||
* /login if no dev-user is in localStorage. This fixture:
|
||||
* 1. Mocks /api/dev/config to return authDisabled: false
|
||||
* 2. Seeds localStorage with a dev user as a fallback
|
||||
* When authDisabled=true, the app uses the dev login selector instead of
|
||||
* Better Auth signIn.social(). This fixture:
|
||||
* 1. Mocks /api/dev/config to return authDisabled: true
|
||||
* 2. Seeds localStorage with a dev user so the selector auto-selects a session
|
||||
*
|
||||
* This ensures E2E tests render pages directly without the login redirect.
|
||||
* This ensures E2E tests render pages directly without the auth redirect.
|
||||
*/
|
||||
const MOCK_DEV_USERS = {
|
||||
staff: [
|
||||
@@ -23,9 +23,9 @@ const MOCK_DEV_USERS = {
|
||||
|
||||
export const test = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
// Mock the dev config endpoint so the app skips the auth-disabled redirect
|
||||
// Mock the dev config endpoint so the app uses dev login selector (bypasses Better Auth)
|
||||
await page.route("**/api/dev/config", (route) =>
|
||||
route.fulfill({ json: { authDisabled: false } })
|
||||
route.fulfill({ json: { authDisabled: true } })
|
||||
);
|
||||
// Mock the dev users endpoint for login selector tests
|
||||
await page.route("**/api/dev/users", (route) =>
|
||||
|
||||
@@ -10,6 +10,15 @@ test.beforeEach(async ({ page }) => {
|
||||
// Reports endpoints need shaped responses (not bare []) to avoid render crashes.
|
||||
await page.route("/api/**", (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes("/api/dev/config")) {
|
||||
return route.fulfill({ json: { authDisabled: true } });
|
||||
}
|
||||
if (url.includes("/api/dev/users")) {
|
||||
return route.fulfill({ json: { staff: [], clients: [] } });
|
||||
}
|
||||
if (url.includes("/api/branding")) {
|
||||
return route.fulfill({ json: { businessName: "GroomBook", logoUrl: null, theme: "default" } });
|
||||
}
|
||||
if (url.includes("/api/reports/summary")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_URL=
|
||||
@@ -0,0 +1,7 @@
|
||||
# Ignore untracked .js files containing JSX (build artifacts)
|
||||
src/__tests__/*.js
|
||||
src/portal/sections/*.js
|
||||
src/portal/*.js
|
||||
src/pages/*.js
|
||||
src/components/*.js
|
||||
src/lib/*.js
|
||||
@@ -1,6 +1,13 @@
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
// Untracked .js files containing JSX (build artifacts)
|
||||
"src/**/*.js",
|
||||
"src/**/*.jsx",
|
||||
],
|
||||
},
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@groombook/types": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"better-auth": "^1.0.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -36,5 +37,6 @@
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
+108
-11
@@ -12,11 +12,68 @@ import { SettingsPage } from "./pages/Settings.js";
|
||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||
import { SetupWizard } from "./pages/SetupWizard.jsx";
|
||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||
import { GlobalSearch } from "./components/GlobalSearch.js";
|
||||
import { useSession, signIn } from "./lib/auth-client.js";
|
||||
|
||||
function LoginPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setIsLoading(true);
|
||||
await signIn.social({ provider: "authentik", callbackURL: window.location.origin });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#f0f2f5",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "2rem 2.5rem",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||
textAlign: "center",
|
||||
minWidth: 280,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 22, marginBottom: "0.5rem", color: "#1a202c" }}>GroomBook</h1>
|
||||
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
|
||||
Sign in to continue
|
||||
</p>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: "#4f8a6f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Redirecting…" : "Sign in with SSO"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ to: "/admin", label: "Appointments" },
|
||||
@@ -133,6 +190,11 @@ function AdminLayout() {
|
||||
export function App() {
|
||||
const location = useLocation();
|
||||
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
||||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||
const { data: rawSession, isPending: rawSessionLoading } = useSession();
|
||||
// In dev mode (authDisabled=true), session state is irrelevant - skip useSession result
|
||||
const session = authDisabled ? null : rawSession;
|
||||
const sessionLoading = authDisabled ? false : rawSessionLoading;
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/dev/config")
|
||||
@@ -141,18 +203,18 @@ export function App() {
|
||||
.catch(() => setAuthDisabled(false));
|
||||
}, []);
|
||||
|
||||
// Show login selector page
|
||||
if (location.pathname === "/login") {
|
||||
return <DevLoginSelector />;
|
||||
}
|
||||
// After session is confirmed, check if setup is needed
|
||||
useEffect(() => {
|
||||
if (authDisabled === null || sessionLoading) return;
|
||||
// Skip if no authenticated session (will redirect to login or dev selector)
|
||||
if (!authDisabled && !session) return;
|
||||
if (authDisabled && !getDevUser()) return;
|
||||
|
||||
// While checking auth config, render nothing briefly
|
||||
if (authDisabled === null) return null;
|
||||
|
||||
// If auth is disabled and no dev user is selected, redirect to login selector
|
||||
if (authDisabled && !getDevUser() && location.pathname !== "/login") {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
fetch("/api/setup/status")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setNeedsSetup(data.needsSetup === true))
|
||||
.catch(() => setNeedsSetup(false));
|
||||
}, [authDisabled, session, sessionLoading]);
|
||||
|
||||
// Public booking redirect pages — no auth or portal chrome needed
|
||||
if (location.pathname === "/booking/confirmed") {
|
||||
@@ -165,6 +227,41 @@ export function App() {
|
||||
return <BookingErrorPage />;
|
||||
}
|
||||
|
||||
// Setup wizard — standalone, no admin chrome
|
||||
if (location.pathname === "/setup") {
|
||||
return (
|
||||
<BrandingProvider>
|
||||
<SetupWizard />
|
||||
</BrandingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Still loading auth state or setup check (skip setup check in dev mode)
|
||||
if (authDisabled === null || sessionLoading) return null;
|
||||
|
||||
// Dev mode: show login selector (no setup check needed in dev mode)
|
||||
if (authDisabled && location.pathname === "/login") {
|
||||
return <DevLoginSelector />;
|
||||
}
|
||||
|
||||
// Dev mode: use dev login selector (no setup check needed in dev mode)
|
||||
if (authDisabled && !getDevUser()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Production: need setup check
|
||||
if (needsSetup === null) return null;
|
||||
|
||||
// Production mode: if no session, redirect to Authentik sign-in
|
||||
if (!authDisabled && !session) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
// Redirect to setup wizard if needed
|
||||
if (needsSetup) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandingProvider>
|
||||
{location.pathname.startsWith("/admin") ? (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { App } from "../App.js";
|
||||
import { App } from "../App";
|
||||
|
||||
|
||||
// Mock fetch to return appropriate responses based on URL
|
||||
beforeEach(() => {
|
||||
@@ -44,6 +45,32 @@ async function renderApp(route = "/admin") {
|
||||
}
|
||||
|
||||
describe("App navigation", () => {
|
||||
// Use authDisabled=true (dev mode) so nav renders without needing Better Auth useSession() mock
|
||||
beforeEach(() => {
|
||||
localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" }));
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: true }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/branding") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
it("renders the Groom Book brand", async () => {
|
||||
const nav = await renderApp();
|
||||
expect(
|
||||
@@ -124,6 +151,12 @@ describe("Dev login selector", () => {
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: null }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import type { Appointment } from "../portal/mockData.js";
|
||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.js";
|
||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
const UPCOMING_APPT: Appointment = {
|
||||
const UPCOMING_APPT = {
|
||||
id: "appt-1",
|
||||
petId: "pet-1",
|
||||
petName: "Buddy",
|
||||
groomerId: "groomer-1",
|
||||
groomerName: "Sarah",
|
||||
services: ["Bath & Brush"],
|
||||
serviceId: "service-1",
|
||||
addOns: [],
|
||||
date: "2027-01-01",
|
||||
time: "10:00 AM",
|
||||
duration: 60,
|
||||
price: 50,
|
||||
status: "confirmed",
|
||||
status: "confirmed" as const,
|
||||
notes: "",
|
||||
customerNotes: "",
|
||||
confirmationStatus: "pending",
|
||||
confirmationStatus: "pending" as const,
|
||||
};
|
||||
|
||||
const PAST_APPT: Appointment = {
|
||||
const PAST_APPT = {
|
||||
...UPCOMING_APPT,
|
||||
id: "appt-2",
|
||||
date: "2025-01-01",
|
||||
time: "10:00 AM",
|
||||
status: "completed",
|
||||
status: "completed" as const,
|
||||
};
|
||||
|
||||
describe("parseTimeTo24Hour", () => {
|
||||
@@ -78,7 +78,7 @@ describe("CustomerNotesSection", () => {
|
||||
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends X-Impersonation-Session-Id header when session exists", async () => {
|
||||
it("sends Authorization header when session exists", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||
@@ -93,14 +93,14 @@ describe("CustomerNotesSection", () => {
|
||||
"/api/portal/appointments/appt-1/notes",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Impersonation-Session-Id": "test-session-id",
|
||||
"Authorization": "Bearer test-session-id",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => {
|
||||
it("does not send Authorization header when sessionId is null", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||
@@ -115,7 +115,7 @@ describe("CustomerNotesSection", () => {
|
||||
"/api/portal/appointments/appt-1/notes",
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
"X-Impersonation-Session-Id": expect.anything(),
|
||||
"Authorization": expect.anything(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -212,7 +212,7 @@ describe("ConfirmationSection", () => {
|
||||
|
||||
it("renders confirmed badge when confirmationStatus is confirmed", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByText("✓ Confirmed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders cancelled badge when confirmationStatus is cancelled", () => {
|
||||
@@ -251,11 +251,11 @@ describe("ConfirmationSection", () => {
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("✓ Confirmed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends X-Impersonation-Session-Id header when session exists", async () => {
|
||||
it("sends Authorization header when session exists", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
@@ -269,14 +269,14 @@ describe("ConfirmationSection", () => {
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Impersonation-Session-Id": "test-session-id",
|
||||
"Authorization": "Bearer test-session-id",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => {
|
||||
it("does not send Authorization header when sessionId is null", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
@@ -290,7 +290,7 @@ describe("ConfirmationSection", () => {
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
"X-Impersonation-Session-Id": expect.anything(),
|
||||
"Authorization": expect.anything(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||
});
|
||||
|
||||
export const { signIn, signOut, useSession } = authClient;
|
||||
@@ -9,6 +9,9 @@ const originalFetch = window.fetch;
|
||||
* Intentionally mutates window.fetch — this is dev-only (AUTH_DISABLED=true).
|
||||
*/
|
||||
export function installDevFetchInterceptor() {
|
||||
// In production, Better-Auth handles auth via cookies — no interception needed
|
||||
if (!import.meta.env.DEV) return;
|
||||
|
||||
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
|
||||
const user = getDevUser();
|
||||
if (!user) return originalFetch(input, init);
|
||||
|
||||
@@ -131,9 +131,18 @@ export function AppointmentsPage() {
|
||||
setError(null);
|
||||
Promise.all([
|
||||
loadAppointments(),
|
||||
fetch("/api/clients").then((r) => r.json() as Promise<Client[]>).then(setClients),
|
||||
fetch("/api/services").then((r) => r.json() as Promise<Service[]>).then(setServices),
|
||||
fetch("/api/staff").then((r) => r.json() as Promise<Staff[]>).then(setStaff),
|
||||
fetch("/api/clients").then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Client[]>;
|
||||
}).then(setClients),
|
||||
fetch("/api/services").then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Service[]>;
|
||||
}).then(setServices),
|
||||
fetch("/api/staff").then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Staff[]>;
|
||||
}).then(setStaff),
|
||||
])
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { Service } from "@groombook/types";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
@@ -107,6 +108,7 @@ export function BookPage() {
|
||||
|
||||
// Step 2 — date & time
|
||||
const [date, setDate] = useState(todayIso());
|
||||
const [dateError, setDateError] = useState<string | null>(null);
|
||||
const [slots, setSlots] = useState<string[]>([]);
|
||||
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
||||
@@ -125,6 +127,28 @@ export function BookPage() {
|
||||
});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// Pre-fill form from URL params (e.g., ?clientName=Jane&clientEmail=jane@example.com)
|
||||
const [searchParams] = useSearchParams();
|
||||
useEffect(() => {
|
||||
const clientName = searchParams.get("clientName");
|
||||
const clientEmail = searchParams.get("clientEmail");
|
||||
const clientPhone = searchParams.get("clientPhone");
|
||||
const petName = searchParams.get("petName");
|
||||
const petSpecies = searchParams.get("petSpecies");
|
||||
const petBreed = searchParams.get("petBreed");
|
||||
if (clientName || clientEmail || clientPhone || petName || petSpecies || petBreed) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
...(clientName && { clientName }),
|
||||
...(clientEmail && { clientEmail }),
|
||||
...(clientPhone && { clientPhone }),
|
||||
...(petName && { petName }),
|
||||
...(petSpecies && { petSpecies }),
|
||||
...(petBreed && { petBreed }),
|
||||
}));
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Step 4 — result
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<BookingResult | null>(null);
|
||||
@@ -328,8 +352,21 @@ export function BookPage() {
|
||||
value={date}
|
||||
min={todayIso()}
|
||||
style={{ ...input, width: "auto" }}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// HTML5 date input enforces yyyy-MM-dd; empty value means invalid format
|
||||
if (!val) {
|
||||
setDateError("Please enter a valid date (YYYY-MM-DD).");
|
||||
setDate("");
|
||||
} else {
|
||||
setDateError(null);
|
||||
setDate(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{dateError && (
|
||||
<p style={{ color: "#dc2626", fontSize: 12, marginTop: 4 }}>{dateError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "1.25rem" }}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface StaffUser {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
@@ -66,7 +67,7 @@ export function DevLoginSelector() {
|
||||
{staff.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => selectUser("staff", s.id, s.name)}
|
||||
onClick={() => selectUser("staff", s.userId ?? s.id, s.name)}
|
||||
style={userButtonStyle}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export { SetupWizard } from "./SetupWizard.jsx";
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
|
||||
const STEPS = [
|
||||
{ title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
||||
{ title: "Business Name", description: "What is the name of your business?" },
|
||||
{ title: "Super User", description: "You will be designated as a Super User with full administrative access." },
|
||||
{ title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." },
|
||||
{ title: "All Set!", description: "Your GroomBook instance is ready to use." },
|
||||
];
|
||||
|
||||
export function SetupWizard() {
|
||||
const navigate = useNavigate();
|
||||
const { refresh: refreshBranding } = useBranding();
|
||||
const [step, setStep] = useState(0);
|
||||
const [businessName, setBusinessName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const current = STEPS[step];
|
||||
const isLast = step === STEPS.length - 1;
|
||||
const canGoBack = step > 0 && step < STEPS.length - 1;
|
||||
const canGoNext = step < STEPS.length - 1 && (step !== 1 || businessName.trim().length > 0);
|
||||
|
||||
const handleNext = async () => {
|
||||
if (step === STEPS.length - 1) {
|
||||
// Done - redirect to admin
|
||||
navigate("/admin");
|
||||
return;
|
||||
}
|
||||
if (step === 1 && businessName.trim()) {
|
||||
// Step 2 (index 1) -> Step 3 (index 2): submit setup
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ businessName: businessName.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error || "Setup failed. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Refresh branding so the nav bar shows the new business name
|
||||
refreshBranding();
|
||||
} catch (e) {
|
||||
setError("Network error. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
setStep((s) => s + 1);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 0) setStep((s) => s - 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#f0f2f5",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}>
|
||||
<div style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.10)",
|
||||
padding: "2.5rem 3rem",
|
||||
maxWidth: 480,
|
||||
width: "100%",
|
||||
}}>
|
||||
{/* Progress dots */}
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
||||
{STEPS.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: i === step ? "#4f8a6f" : i < step ? "#4f8a6f" : "#e2e8f0",
|
||||
opacity: i === step ? 1 : i < step ? 0.5 : 1,
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
||||
Step {step + 1} of {STEPS.length}
|
||||
</p>
|
||||
|
||||
{/* Title */}
|
||||
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
||||
{current.title}
|
||||
</h2>
|
||||
|
||||
{/* Description */}
|
||||
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
||||
{current.description}
|
||||
</p>
|
||||
|
||||
{/* Step 2: Business name input */}
|
||||
{step === 1 && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Happy Paws Grooming"
|
||||
value={businessName}
|
||||
onChange={(e) => setBusinessName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
|
||||
autoFocus
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.6rem 0.85rem",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #d1d5db",
|
||||
fontSize: 15,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
marginBottom: error ? "0.5rem" : 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: Info about super user */}
|
||||
{step === 2 && (
|
||||
<div style={{
|
||||
background: "#f0fdf4",
|
||||
border: "1px solid #bbf7d0",
|
||||
borderRadius: 8,
|
||||
padding: "0.85rem 1rem",
|
||||
fontSize: 14,
|
||||
color: "#166534",
|
||||
marginBottom: "1rem",
|
||||
}}>
|
||||
As a Super User, you can manage all settings, staff, and appointments.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Info about second admin */}
|
||||
{step === 3 && (
|
||||
<div style={{
|
||||
background: "#fffbeb",
|
||||
border: "1px solid #fde68a",
|
||||
borderRadius: 8,
|
||||
padding: "0.85rem 1rem",
|
||||
fontSize: 14,
|
||||
color: "#92400e",
|
||||
}}>
|
||||
You can add additional Super Users from the Staff management page after setup.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p style={{
|
||||
margin: "0.5rem 0 0",
|
||||
fontSize: 13,
|
||||
color: "#dc2626",
|
||||
background: "#fef2f2",
|
||||
border: "1px solid #fecaca",
|
||||
borderRadius: 6,
|
||||
padding: "0.5rem 0.75rem",
|
||||
}}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
marginTop: step === 3 ? "1.5rem" : "1.25rem",
|
||||
justifyContent: step === 0 ? "flex-end" : "space-between",
|
||||
}}>
|
||||
{canGoBack && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: "0.55rem 1.1rem",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #d1d5db",
|
||||
background: "#fff",
|
||||
color: "#374151",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!canGoNext || loading}
|
||||
style={{
|
||||
padding: "0.55rem 1.25rem",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: canGoNext && !loading ? "#4f8a6f" : "#9ca3af",
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: canGoNext && !loading ? "pointer" : "not-allowed",
|
||||
opacity: loading ? 0.7 : 1,
|
||||
marginLeft: canGoBack ? 0 : "auto",
|
||||
}}
|
||||
>
|
||||
{loading ? "Setting up..." : isLast ? "Go to Dashboard" : step === 1 ? "Continue" : "Next"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Settings, LogOut, Shield,
|
||||
} from "lucide-react";
|
||||
import { Dashboard } from "./sections/Dashboard.js";
|
||||
import { AppointmentsSection } from "./sections/Appointments.js";
|
||||
import { AppointmentsSection, RescheduleFlow } from "./sections/Appointments.js";
|
||||
import { PetProfiles } from "./sections/PetProfiles.js";
|
||||
import { ReportCards } from "./sections/ReportCards.js";
|
||||
import { BillingPayments } from "./sections/BillingPayments.js";
|
||||
@@ -13,7 +13,6 @@ import { Communication } from "./sections/Communication.js";
|
||||
import { AccountSettings } from "./sections/AccountSettings.js";
|
||||
import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||
import { CUSTOMER } from "./mockData.js";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
import type { ImpersonationSession } from "@groombook/types";
|
||||
|
||||
@@ -33,8 +32,11 @@ export function CustomerPortal() {
|
||||
const [activeSection, setActiveSection] = useState<Section>("dashboard");
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||
const [showReschedule, setShowReschedule] = useState(false);
|
||||
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
|
||||
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||
const [sessionExtended, setSessionExtended] = useState(false);
|
||||
const [clientName, setClientName] = useState<string>("");
|
||||
const { branding } = useBranding();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -55,6 +57,11 @@ export function CustomerPortal() {
|
||||
.then((s) => {
|
||||
if (s && s.status === "active") {
|
||||
setSession(s);
|
||||
// Fetch client name for display
|
||||
fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data?.name) setClientName(data.name); })
|
||||
.catch(() => {});
|
||||
}
|
||||
// Clean sessionId from URL
|
||||
setSearchParams({}, { replace: true });
|
||||
@@ -107,27 +114,37 @@ export function CustomerPortal() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReschedule = useCallback((appointmentId: string) => {
|
||||
// Look up the full appointment from Dashboard's displayed data
|
||||
// The appointment was already fetched by Dashboard, so we use the ID to find it
|
||||
setRescheduleAppointment({ id: appointmentId } as Record<string, unknown>);
|
||||
setShowReschedule(true);
|
||||
}, []);
|
||||
|
||||
const isReadOnly = session?.status === "active";
|
||||
|
||||
const renderSection = () => {
|
||||
const sessionId = session?.id ?? null;
|
||||
switch (activeSection) {
|
||||
case "dashboard":
|
||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} />;
|
||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} />;
|
||||
case "appointments":
|
||||
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={session?.id ?? null} />;
|
||||
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||
case "pets":
|
||||
return <PetProfiles readOnly={!!isReadOnly} />;
|
||||
return <PetProfiles readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||
case "reports":
|
||||
return <ReportCards />;
|
||||
case "billing":
|
||||
return <BillingPayments readOnly={!!isReadOnly} />;
|
||||
return <BillingPayments readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||
case "messages":
|
||||
return <Communication readOnly={!!isReadOnly} />;
|
||||
case "settings":
|
||||
return <AccountSettings readOnly={!!isReadOnly} />;
|
||||
return <AccountSettings readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||
}
|
||||
};
|
||||
|
||||
const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-[#faf8f5] font-sans"
|
||||
@@ -158,6 +175,15 @@ export function CustomerPortal() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showReschedule && rescheduleAppointment && (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<RescheduleFlow
|
||||
appointment={rescheduleAppointment as any}
|
||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||
sessionId={session?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Header */}
|
||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-stone-200">
|
||||
<button
|
||||
@@ -171,7 +197,7 @@ export function CustomerPortal() {
|
||||
</button>
|
||||
<span className="text-lg font-semibold text-stone-800">{branding.businessName}</span>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
||||
SM
|
||||
{avatarInitials}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -258,9 +284,9 @@ export function CustomerPortal() {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-stone-600">Hi, {CUSTOMER.name.split(" ")[0]}</span>
|
||||
<span className="text-sm text-stone-600">Hi, {clientName.split(" ")[0] || "Guest"}</span>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
||||
SM
|
||||
{avatarInitials}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
||||
import { CUSTOMER, PETS, SIGNED_AGREEMENTS } from "../mockData.js";
|
||||
import { PetForm } from "./PetForm.js";
|
||||
|
||||
interface Props {
|
||||
sessionId: string | null;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export function AccountSettings({ readOnly }: Props) {
|
||||
interface PersonalInfoData {
|
||||
id?: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
interface PetData {
|
||||
id: string;
|
||||
name: string;
|
||||
species?: string;
|
||||
breed?: string;
|
||||
weight?: number;
|
||||
photo?: string;
|
||||
}
|
||||
|
||||
export function AccountSettings({ sessionId, readOnly }: Props) {
|
||||
const [tab, setTab] = useState<"personal" | "password" | "pets" | "agreements">("personal");
|
||||
|
||||
return (
|
||||
@@ -31,21 +50,65 @@ export function AccountSettings({ readOnly }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "personal" && <PersonalInfo readOnly={readOnly} />}
|
||||
{tab === "personal" && <PersonalInfo sessionId={sessionId} readOnly={readOnly} />}
|
||||
{tab === "password" && <PasswordChange readOnly={readOnly} />}
|
||||
{tab === "pets" && <ManagePets readOnly={readOnly} />}
|
||||
{tab === "pets" && <ManagePets sessionId={sessionId} readOnly={readOnly} />}
|
||||
{tab === "agreements" && <Agreements />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalInfo({ readOnly }: { readOnly: boolean }) {
|
||||
function PersonalInfo({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) {
|
||||
const [form, setForm] = useState({
|
||||
name: CUSTOMER.name,
|
||||
email: CUSTOMER.email,
|
||||
phone: CUSTOMER.phone,
|
||||
address: CUSTOMER.address,
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPersonalInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/portal/me");
|
||||
if (response.ok) {
|
||||
const data: PersonalInfoData = await response.json();
|
||||
setForm({
|
||||
name: [data.firstName, data.lastName].filter(Boolean).join(" ") || "",
|
||||
email: data.email || "",
|
||||
phone: data.phone || "",
|
||||
address: data.address || "",
|
||||
});
|
||||
} else {
|
||||
setError("Failed to load personal info");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load personal info");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPersonalInfo();
|
||||
}, [sessionId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<p className="text-sm text-stone-500">Loading personal info...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
@@ -111,10 +174,67 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ManagePets({ readOnly }: { readOnly: boolean }) {
|
||||
function ManagePets({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) {
|
||||
const [pets, setPets] = useState<PetData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/portal/pets");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPets(Array.isArray(data) ? data : []);
|
||||
} else {
|
||||
setError("Failed to load pets");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load pets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPets();
|
||||
}, [sessionId]);
|
||||
|
||||
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? undefined : undefined;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<p className="text-sm text-stone-500">Loading pets...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (editingPet || showAddForm) {
|
||||
return (
|
||||
<PetForm
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pet={(editingPet ?? undefined) as any}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onSave={() => { setEditingPetId(null); setShowAddForm(false); }}
|
||||
onCancel={() => { setEditingPetId(null); setShowAddForm(false); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{PETS.map(pet => (
|
||||
{pets.map(pet => (
|
||||
<div key={pet.id} className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-(--color-accent-light) flex items-center justify-center text-3xl">
|
||||
{pet.photo}
|
||||
@@ -125,7 +245,10 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 border border-stone-200 rounded-lg text-xs text-stone-600 hover:bg-stone-50">
|
||||
<button
|
||||
onClick={() => setEditingPetId(pet.id)}
|
||||
className="px-3 py-1.5 border border-stone-200 rounded-lg text-xs text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button className="p-1.5 border border-stone-200 rounded-lg text-stone-400 hover:text-amber-600 hover:border-amber-200">
|
||||
@@ -136,7 +259,10 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-(--color-accent) hover:text-(--color-accent-dark) transition-colors">
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-(--color-accent) hover:text-(--color-accent-dark) transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add New Pet
|
||||
</button>
|
||||
@@ -147,31 +273,10 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
|
||||
|
||||
function Agreements() {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
|
||||
<th className="px-5 py-3 font-medium">Document</th>
|
||||
<th className="px-5 py-3 font-medium">Date Signed</th>
|
||||
<th className="px-5 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{SIGNED_AGREEMENTS.map(agr => (
|
||||
<tr key={agr.id} className="border-b border-stone-50">
|
||||
<td className="px-5 py-3 font-medium text-stone-800">{agr.name}</td>
|
||||
<td className="px-5 py-3 text-stone-600">
|
||||
{new Date(agr.dateSigned).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<button className="text-sm text-(--color-accent-dark) font-medium hover:underline">View</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<p className="text-sm text-stone-500">
|
||||
No agreements found. There is currently no agreements table in the database.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import { CreditCard, Download, DollarSign, Package, Zap, Plus, Trash2 } from "lucide-react";
|
||||
import { INVOICES, SAVED_PAYMENT_METHODS, PREPAID_PACKAGES, Invoice } from "../mockData.js";
|
||||
import { useState, useEffect } from "react";
|
||||
import { CreditCard, Download, DollarSign, Package, Zap } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
interface Invoice {
|
||||
id: string;
|
||||
status: "pending" | "paid" | "failed" | "refunded";
|
||||
totalCents: number;
|
||||
date: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface PaymentMethod {
|
||||
brand: string;
|
||||
last4: string;
|
||||
expiryMonth: number;
|
||||
expiryYear: number;
|
||||
}
|
||||
|
||||
interface Package {
|
||||
name: string;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
interface BillingPaymentsProps {
|
||||
sessionId: string | null;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
paid: "bg-green-100 text-green-700",
|
||||
outstanding: "bg-amber-100 text-amber-700",
|
||||
overdue: "bg-red-100 text-red-700",
|
||||
pending: "bg-yellow-100 text-yellow-700",
|
||||
failed: "bg-red-100 text-red-700",
|
||||
refunded: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
export function BillingPayments({ readOnly }: Props) {
|
||||
export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||
const [packages, setPackages] = useState<Package[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
||||
const [autopay, setAutopay] = useState(false);
|
||||
const [showTipModal, setShowTipModal] = useState(false);
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
|
||||
const outstanding = INVOICES.filter(i => i.status === "outstanding");
|
||||
const totalOutstanding = outstanding.reduce((sum, i) => sum + i.amount, 0);
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!sessionId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/portal/invoices", {
|
||||
headers: {
|
||||
"x-session-id": sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch invoices");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setInvoices(data.invoices || []);
|
||||
setPaymentMethods(data.paymentMethods || []);
|
||||
setPackages(data.packages || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [sessionId]);
|
||||
|
||||
const formatCents = (cents: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(cents / 100);
|
||||
};
|
||||
|
||||
const pending = invoices.filter((i) => i.status === "pending");
|
||||
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="h-24 bg-gray-200 rounded"></div>
|
||||
<div className="h-24 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-red-600">Error: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Outstanding Balance Banner */}
|
||||
{totalOutstanding > 0 && (
|
||||
{totalPending > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-stone-500">Outstanding Balance</p>
|
||||
<p className="text-3xl font-bold text-stone-800">${totalOutstanding.toFixed(2)}</p>
|
||||
<p className="text-xs text-stone-400 mt-0.5">{outstanding.length} unpaid invoice{outstanding.length > 1 ? "s" : ""}</p>
|
||||
<p className="text-3xl font-bold text-stone-800">{formatCents(totalPending)}</p>
|
||||
<p className="text-xs text-stone-400 mt-0.5">
|
||||
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowTipModal(true)}
|
||||
className="px-4 py-2 border border-stone-200 rounded-lg text-sm font-medium text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Add Tip
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPaymentModal(true)}
|
||||
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
Pay Now
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPaymentModal(true)}
|
||||
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
Pay Now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -61,7 +139,9 @@ export function BillingPayments({ readOnly }: Props) {
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
|
||||
tab === id
|
||||
? "bg-(--color-accent-light) text-(--color-accent-dark)"
|
||||
: "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
@@ -78,23 +158,35 @@ export function BillingPayments({ readOnly }: Props) {
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
|
||||
<th className="px-5 py-3 font-medium">Date</th>
|
||||
<th className="px-5 py-3 font-medium">Items</th>
|
||||
<th className="px-5 py-3 font-medium">Description</th>
|
||||
<th className="px-5 py-3 font-medium">Amount</th>
|
||||
<th className="px-5 py-3 font-medium">Status</th>
|
||||
<th className="px-5 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{INVOICES.map(inv => (
|
||||
{invoices.map((inv) => (
|
||||
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
||||
<td className="px-5 py-3 text-stone-700">
|
||||
{new Date(inv.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
{new Date(inv.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-stone-600">
|
||||
{inv.description || `Invoice ${inv.id.slice(0, 8)}`}
|
||||
</td>
|
||||
<td className="px-5 py-3 font-medium text-stone-800">
|
||||
{formatCents(inv.totalCents)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-stone-600">{inv.items.join(", ")}</td>
|
||||
<td className="px-5 py-3 font-medium text-stone-800">${inv.amount.toFixed(2)}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_STYLES[inv.status]}`}>
|
||||
{inv.status}
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_STYLES[inv.status] || "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{inv.status.charAt(0).toUpperCase() + inv.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
@@ -113,37 +205,33 @@ export function BillingPayments({ readOnly }: Props) {
|
||||
{/* Payment Methods */}
|
||||
{tab === "payment" && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm space-y-3">
|
||||
{SAVED_PAYMENT_METHODS.map(pm => (
|
||||
<div key={pm.id} className="flex items-center justify-between py-2 border-b border-stone-50 last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-stone-100 flex items-center justify-center">
|
||||
<CreditCard size={18} className="text-stone-500" />
|
||||
{paymentMethods.length === 0 ? (
|
||||
<p className="text-gray-500 italic">No payment methods on file</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.map((method) => (
|
||||
<div
|
||||
key={`${method.brand}-${method.last4}`}
|
||||
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-6 bg-gray-200 rounded flex items-center justify-center text-xs">
|
||||
{method.brand.toUpperCase()}
|
||||
</div>
|
||||
<span className="text-stone-700">**** {method.last4}</span>
|
||||
<span className="text-stone-500">
|
||||
{method.expiryMonth}/{method.expiryYear}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-800 capitalize">{pm.type} •••• {pm.last4}</p>
|
||||
<p className="text-xs text-stone-400">Expires {pm.expiry}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{pm.isDefault && (
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Default</span>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button className="p-1 text-stone-400 hover:text-red-500">
|
||||
<Trash2 size={14} />
|
||||
<button className="text-sm text-blue-600 hover:underline">
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button className="flex items-center gap-2 text-sm text-(--color-accent-dark) font-medium hover:underline mt-2">
|
||||
<Plus size={14} />
|
||||
Add Payment Method
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Autopay */}
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
@@ -154,18 +242,28 @@ export function BillingPayments({ readOnly }: Props) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
||||
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
|
||||
<p className="text-xs text-stone-500">
|
||||
Automatically charge after each appointment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<button
|
||||
onClick={() => setAutopay(!autopay)}
|
||||
className={`w-12 h-6 rounded-full transition-colors ${autopay ? "bg-(--color-accent)" : "bg-stone-300"}`}
|
||||
className={`w-12 h-6 rounded-full transition-colors ${
|
||||
autopay ? "bg-(--color-accent)" : "bg-stone-300"
|
||||
}`}
|
||||
>
|
||||
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ${autopay ? "translate-x-6" : "translate-x-0.5"}`} />
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full shadow transition-transform ${
|
||||
autopay ? "translate-x-6" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-stone-400">{autopay ? "Enabled" : "Disabled"}</span>
|
||||
<span className="text-xs text-stone-400">
|
||||
{autopay ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,40 +273,29 @@ export function BillingPayments({ readOnly }: Props) {
|
||||
{/* Packages */}
|
||||
{tab === "packages" && (
|
||||
<div className="space-y-4">
|
||||
{PREPAID_PACKAGES.map(pkg => (
|
||||
<div key={pkg.id} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Package size={20} className="text-(--color-accent)" />
|
||||
<h3 className="font-medium text-stone-800">{pkg.name}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-stone-800">{pkg.totalCredits - pkg.usedCredits}</p>
|
||||
<p className="text-xs text-stone-500">remaining of {pkg.totalCredits}</p>
|
||||
</div>
|
||||
<div className="flex-1 bg-stone-100 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-(--color-accent) h-full rounded-full"
|
||||
style={{ width: `${((pkg.totalCredits - pkg.usedCredits) / pkg.totalCredits) * 100}%` }}
|
||||
/>
|
||||
{packages.length === 0 ? (
|
||||
<p className="text-gray-500 italic">No packages purchased</p>
|
||||
) : (
|
||||
packages.map((pkg, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-stone-800">{pkg.name}</span>
|
||||
<span className="text-stone-600">{pkg.remaining} remaining</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-stone-400">Expires {new Date(pkg.expiresAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tip Modal */}
|
||||
{showTipModal && !readOnly && (
|
||||
<TipModal onClose={() => setShowTipModal(false)} />
|
||||
)}
|
||||
|
||||
{/* Payment Modal */}
|
||||
{showPaymentModal && !readOnly && (
|
||||
<PaymentModal
|
||||
outstanding={outstanding}
|
||||
totalOutstanding={totalOutstanding}
|
||||
pending={pending}
|
||||
totalPending={totalPending}
|
||||
onClose={() => setShowPaymentModal(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -216,11 +303,27 @@ export function BillingPayments({ readOnly }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClose }: { outstanding: Invoice[]; totalOutstanding: number; onClose: () => void }) {
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set(outstanding.map(i => i.id)));
|
||||
function PaymentModal({
|
||||
pending,
|
||||
totalPending: _totalPending,
|
||||
onClose,
|
||||
}: {
|
||||
pending: Invoice[];
|
||||
totalPending: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
|
||||
new Set(pending.map((i) => i.id))
|
||||
);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
|
||||
const formatCents = (cents: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(cents / 100);
|
||||
|
||||
const toggleInvoice = (id: string) => {
|
||||
const next = new Set(selectedInvoices);
|
||||
if (next.has(id)) {
|
||||
@@ -233,26 +336,45 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
||||
|
||||
const handlePay = async () => {
|
||||
setIsProcessing(true);
|
||||
// Simulate payment processing
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
setIsProcessing(false);
|
||||
setIsComplete(true);
|
||||
};
|
||||
|
||||
const selectedTotal = outstanding.filter(i => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.amount, 0);
|
||||
const selectedTotal = pending
|
||||
.filter((i) => selectedInvoices.has(i.id))
|
||||
.reduce((sum, i) => sum + i.totalCents, 0);
|
||||
|
||||
if (isComplete) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
className="w-8 h-8 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="font-semibold text-stone-800 text-lg mb-2">Payment Successful</h2>
|
||||
<p className="text-stone-500 text-sm mb-6">Your payment of ${selectedTotal.toFixed(2)} has been processed. A receipt has been sent to your email.</p>
|
||||
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
|
||||
<h2 className="font-semibold text-stone-800 text-lg mb-2">
|
||||
Payment Successful
|
||||
</h2>
|
||||
<p className="text-stone-500 text-sm mb-6">
|
||||
Your payment of {formatCents(selectedTotal)} has been processed. A
|
||||
receipt has been sent to your email.
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
@@ -264,10 +386,25 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
|
||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<h2 className="font-semibold text-stone-800 text-lg">
|
||||
Pay Outstanding Balance
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-stone-400 hover:text-stone-600"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -275,11 +412,13 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
||||
<p className="text-sm text-stone-500 mb-4">Select invoices to pay:</p>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{outstanding.map(inv => (
|
||||
{pending.map((inv) => (
|
||||
<label
|
||||
key={inv.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedInvoices.has(inv.id) ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
|
||||
selectedInvoices.has(inv.id)
|
||||
? "border-(--color-accent) bg-(--color-accent-lighter)"
|
||||
: "border-stone-200 hover:border-stone-300"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -290,11 +429,17 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
||||
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-800">{inv.items.join(", ")}</p>
|
||||
<p className="text-xs text-stone-500">{new Date(inv.date).toLocaleDateString()}</p>
|
||||
<p className="text-sm font-medium text-stone-800">
|
||||
{inv.description || `Invoice ${inv.id.slice(0, 8)}`}
|
||||
</p>
|
||||
<p className="text-xs text-stone-500">
|
||||
{new Date(inv.date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-stone-800">${inv.amount.toFixed(2)}</span>
|
||||
<span className="text-sm font-medium text-stone-800">
|
||||
{formatCents(inv.totalCents)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -302,7 +447,9 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
||||
<div className="border-t border-stone-200 pt-4 mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-stone-600">Total</span>
|
||||
<span className="text-lg font-bold text-stone-800">${selectedTotal.toFixed(2)}</span>
|
||||
<span className="text-lg font-bold text-stone-800">
|
||||
{formatCents(selectedTotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -326,50 +473,4 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
||||
);
|
||||
}
|
||||
|
||||
function TipModal({ onClose }: { onClose: () => void }) {
|
||||
const [tipPercent, setTipPercent] = useState<number | null>(20);
|
||||
const [customTip, setCustomTip] = useState("");
|
||||
const presets = [15, 20, 25];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full p-6">
|
||||
<h2 className="font-semibold text-stone-800 mb-4">Add a Tip</h2>
|
||||
<div className="flex gap-2 mb-4">
|
||||
{presets.map(pct => (
|
||||
<button
|
||||
key={pct}
|
||||
onClick={() => { setTipPercent(pct); setCustomTip(""); }}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
|
||||
tipPercent === pct ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600"
|
||||
}`}
|
||||
>
|
||||
{pct}%
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { setTipPercent(null); }}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
|
||||
tipPercent === null ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600"
|
||||
}`}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
{tipPercent === null && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Enter amount"
|
||||
value={customTip}
|
||||
onChange={e => setCustomTip(e.target.value)}
|
||||
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm mb-4"
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm">Cancel</button>
|
||||
<button onClick={onClose} className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">Add Tip</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default BillingPayments;
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
|
||||
import { MESSAGES, BUSINESS_NAME } from "../mockData.js";
|
||||
import type { Message } from "../mockData.js";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
sender: "customer" | "business";
|
||||
senderName: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
interface NotificationCategory {
|
||||
email: boolean;
|
||||
sms: boolean;
|
||||
push: boolean;
|
||||
}
|
||||
|
||||
interface NotificationPreferences {
|
||||
appointmentReminders: NotificationCategory;
|
||||
vaccinationAlerts: NotificationCategory;
|
||||
promotional: NotificationCategory;
|
||||
reportCards: NotificationCategory;
|
||||
invoiceReceipts: NotificationCategory;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
readOnly: boolean;
|
||||
@@ -39,15 +60,31 @@ export function Communication({ readOnly }: Props) {
|
||||
}
|
||||
|
||||
function MessageThread({ readOnly }: { readOnly: boolean }) {
|
||||
const [messages, setMessages] = useState<Message[]>(MESSAGES);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [businessName, setBusinessName] = useState<string>("Business");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBranding() {
|
||||
try {
|
||||
const response = await fetch("/api/branding");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBusinessName(data.businessName || data.name || "Business");
|
||||
}
|
||||
} catch {
|
||||
setBusinessName("Business");
|
||||
}
|
||||
}
|
||||
fetchBranding();
|
||||
}, []);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!newMessage.trim() || readOnly) return;
|
||||
const msg: Message = {
|
||||
id: `m-${Date.now()}`,
|
||||
sender: "customer",
|
||||
senderName: "Sarah",
|
||||
senderName: "You",
|
||||
text: newMessage.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
@@ -59,32 +96,36 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
|
||||
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
|
||||
<p className="text-sm font-medium text-stone-800">{BUSINESS_NAME}</p>
|
||||
<p className="text-sm font-medium text-stone-800">{businessName}</p>
|
||||
<p className="text-xs text-stone-400">Usually replies within a few hours</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
|
||||
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
|
||||
msg.sender === "customer"
|
||||
? "bg-(--color-accent) text-white rounded-br-md"
|
||||
: "bg-stone-100 text-stone-800 rounded-bl-md"
|
||||
}`}>
|
||||
<p className="text-sm">{msg.text}</p>
|
||||
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
|
||||
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
|
||||
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||||
</span>
|
||||
{msg.sender === "customer" && (
|
||||
msg.read
|
||||
? <CheckCheck size={12} className="text-white/60" />
|
||||
: <Check size={12} className="text-white/60" />
|
||||
)}
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-stone-400 text-center text-sm italic">No messages yet</p>
|
||||
) : (
|
||||
messages.map(msg => (
|
||||
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
|
||||
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
|
||||
msg.sender === "customer"
|
||||
? "bg-(--color-accent) text-white rounded-br-md"
|
||||
: "bg-stone-100 text-stone-800 rounded-bl-md"
|
||||
}`}>
|
||||
<p className="text-sm">{msg.text}</p>
|
||||
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
|
||||
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
|
||||
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||||
</span>
|
||||
{msg.sender === "customer" && (
|
||||
msg.read
|
||||
? <CheckCheck size={12} className="text-white/60" />
|
||||
: <Check size={12} className="text-white/60" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
@@ -111,7 +152,7 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
|
||||
}
|
||||
|
||||
function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
||||
const [prefs, setPrefs] = useState({
|
||||
const [prefs, setPrefs] = useState<NotificationPreferences>({
|
||||
appointmentReminders: { email: true, sms: true, push: true },
|
||||
vaccinationAlerts: { email: true, sms: false, push: true },
|
||||
promotional: { email: false, sms: false, push: false },
|
||||
@@ -119,7 +160,7 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
||||
invoiceReceipts: { email: true, sms: false, push: false },
|
||||
});
|
||||
|
||||
type PrefKey = keyof typeof prefs;
|
||||
type PrefKey = keyof NotificationPreferences;
|
||||
type ChannelKey = "email" | "sms" | "push";
|
||||
|
||||
const toggle = (category: PrefKey, channel: ChannelKey) => {
|
||||
@@ -194,3 +235,5 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Communication;
|
||||
|
||||
@@ -1,9 +1,53 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
|
||||
import { PETS, UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, INVOICES, LOYALTY, BUSINESS_NAME } from "../mockData.js";
|
||||
|
||||
interface Props {
|
||||
interface DashboardProps {
|
||||
sessionId: string | null;
|
||||
clientName: string;
|
||||
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
|
||||
readOnly: boolean;
|
||||
onReschedule: (appointmentId: string) => void;
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
date: string;
|
||||
time: string;
|
||||
petName: string;
|
||||
serviceName: string;
|
||||
status: string;
|
||||
staffName?: string;
|
||||
services?: string[];
|
||||
addOns?: string[];
|
||||
groomerName?: string;
|
||||
}
|
||||
|
||||
interface Pet {
|
||||
id: string;
|
||||
name: string;
|
||||
species: string;
|
||||
breed?: string;
|
||||
dateOfBirth?: string;
|
||||
weight?: number;
|
||||
healthAlerts: string[];
|
||||
photo?: string;
|
||||
vaccinations?: { name: string; status: string }[];
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
dueDate?: string;
|
||||
items: { description: string; price: number }[];
|
||||
}
|
||||
|
||||
interface Branding {
|
||||
clinicName: string;
|
||||
logoUrl?: string;
|
||||
primaryColor: string;
|
||||
}
|
||||
|
||||
function daysUntil(dateStr: string): number {
|
||||
@@ -15,27 +59,154 @@ function daysUntil(dateStr: string): number {
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function Dashboard({ onNavigate, readOnly }: Props) {
|
||||
const nextAppt = UPCOMING_APPOINTMENTS[0];
|
||||
const outstanding = INVOICES.filter(i => i.status === "outstanding").reduce((sum, i) => sum + i.amount, 0);
|
||||
const recentEvents = [
|
||||
...PAST_APPOINTMENTS.slice(0, 3).map(a => ({
|
||||
id: a.id, date: a.date, text: `${a.petName} — ${a.services.join(", ")}`, type: "appointment" as const,
|
||||
})),
|
||||
...INVOICES.filter(i => i.status === "paid").slice(0, 2).map(i => ({
|
||||
id: i.id, date: i.date, text: `Invoice paid — $${i.amount}`, type: "payment" as const,
|
||||
})),
|
||||
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5);
|
||||
export function Dashboard({
|
||||
sessionId,
|
||||
clientName,
|
||||
onNavigate,
|
||||
readOnly,
|
||||
onReschedule,
|
||||
}: DashboardProps) {
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [pendingInvoices, setPendingInvoices] = useState<Invoice[]>([]);
|
||||
const [branding, setBranding] = useState<Branding | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!sessionId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
"x-session-id": sessionId,
|
||||
};
|
||||
|
||||
const [appointmentsRes, petsRes, invoicesRes, brandingRes] = await Promise.all([
|
||||
fetch("/api/portal/appointments", { headers }),
|
||||
fetch("/api/portal/pets", { headers }),
|
||||
fetch("/api/portal/invoices", { headers }),
|
||||
fetch("/api/branding", { headers }),
|
||||
]);
|
||||
|
||||
if (!appointmentsRes.ok || !petsRes.ok || !invoicesRes.ok || !brandingRes.ok) {
|
||||
throw new Error("Failed to fetch dashboard data");
|
||||
}
|
||||
|
||||
const appointmentsData = await appointmentsRes.json();
|
||||
const petsData = await petsRes.json();
|
||||
const invoicesData = await invoicesRes.json();
|
||||
const brandingData = await brandingRes.json();
|
||||
|
||||
setAppointments(appointmentsData.appointments || []);
|
||||
setPets(petsData.pets || []);
|
||||
|
||||
// Filter for pending invoices only (not "outstanding")
|
||||
const pending = (invoicesData.invoices || []).filter(
|
||||
(invoice: Invoice) => invoice.status === "pending"
|
||||
);
|
||||
setPendingInvoices(pending);
|
||||
|
||||
setBranding(brandingData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [sessionId]);
|
||||
|
||||
const getUpcomingAppointments = (): Appointment[] => {
|
||||
const now = new Date();
|
||||
return appointments
|
||||
.filter((apt) => new Date(`${apt.date}T${apt.time}`) >= now)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(`${a.date}T${a.time}`).getTime() -
|
||||
new Date(`${b.date}T${b.time}`).getTime()
|
||||
)
|
||||
.slice(0, 5);
|
||||
};
|
||||
|
||||
const getPetHealthAlerts = (): { petName: string; alert: string }[] => {
|
||||
return pets
|
||||
.filter((pet) => pet.healthAlerts && pet.healthAlerts.length > 0)
|
||||
.flatMap((pet) =>
|
||||
pet.healthAlerts.map((alert) => ({ petName: pet.name, alert }))
|
||||
);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number): string => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getPendingBalance = (): number => {
|
||||
return pendingInvoices.reduce((sum, invoice) => sum + invoice.amount, 0);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-(--color-accent)" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-2xl p-5">
|
||||
<p className="text-red-700">Error: {error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-stone-100 rounded-2xl p-5 text-center">
|
||||
<p className="text-stone-600">Please sign in to view your dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const upcomingAppointments = getUpcomingAppointments();
|
||||
const healthAlerts = getPetHealthAlerts();
|
||||
const pendingBalance = getPendingBalance();
|
||||
const nextAppt = upcomingAppointments[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-stone-800">Welcome back, Sarah</h2>
|
||||
<p className="text-stone-500 text-sm mt-1">Here's what's happening at {BUSINESS_NAME}</p>
|
||||
<h2 className="text-2xl font-semibold text-stone-800">
|
||||
Welcome back, {clientName}
|
||||
</h2>
|
||||
<p className="text-stone-500 text-sm mt-1">
|
||||
Here's what's happening at {branding?.clinicName || "your clinic"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Next Appointment */}
|
||||
@@ -53,11 +224,16 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-lg font-semibold text-stone-800">
|
||||
{nextAppt.petName} with {nextAppt.groomerName}
|
||||
{nextAppt.petName}
|
||||
{nextAppt.groomerName && ` with ${nextAppt.groomerName}`}
|
||||
{nextAppt.staffName && ` with ${nextAppt.staffName}`}
|
||||
</p>
|
||||
<p className="text-stone-600 text-sm mt-1">
|
||||
{nextAppt.services.join(", ")}
|
||||
{nextAppt.addOns.length > 0 && ` + ${nextAppt.addOns.join(", ")}`}
|
||||
{nextAppt.services?.join(", ") ||
|
||||
nextAppt.serviceName ||
|
||||
"Appointment"}
|
||||
{nextAppt.addOns && nextAppt.addOns.length > 0 &&
|
||||
` + ${nextAppt.addOns.join(", ")}`}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-stone-500">
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -71,13 +247,18 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center sm:text-right">
|
||||
<div className="text-3xl font-bold text-(--color-accent-dark)">{daysUntil(nextAppt.date)}</div>
|
||||
<div className="text-3xl font-bold text-(--color-accent-dark)">
|
||||
{daysUntil(nextAppt.date)}
|
||||
</div>
|
||||
<div className="text-xs text-stone-500">days away</div>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
||||
<button
|
||||
onClick={() => onReschedule(nextAppt.id)}
|
||||
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Reschedule
|
||||
</button>
|
||||
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
||||
@@ -94,8 +275,8 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
|
||||
{/* Pet Cards & Loyalty */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Pet Cards */}
|
||||
{PETS.map(pet => {
|
||||
const expiringVax = pet.vaccinations.filter(v => v.status !== "valid");
|
||||
{pets.map((pet) => {
|
||||
const petAlerts = pet.healthAlerts || [];
|
||||
return (
|
||||
<button
|
||||
key={pet.id}
|
||||
@@ -104,59 +285,63 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-12 h-12 rounded-full bg-(--color-accent-light) flex items-center justify-center text-2xl">
|
||||
{pet.photo}
|
||||
{pet.photo || pet.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-800">{pet.name}</p>
|
||||
<p className="text-xs text-stone-500">{pet.breed} · {pet.weight} lbs</p>
|
||||
<p className="text-xs text-stone-500">
|
||||
{pet.breed || pet.species}
|
||||
{pet.weight && ` · ${pet.weight} lbs`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{expiringVax.length > 0 ? (
|
||||
{petAlerts.length > 0 ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded-lg">
|
||||
<AlertTriangle size={12} />
|
||||
{expiringVax.map(v => v.name).join(", ")} {expiringVax[0]?.status === "expired" ? "expired" : "expiring soon"}
|
||||
{petAlerts.join(", ")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-xs text-green-700 bg-green-50 px-2 py-1 rounded-lg">
|
||||
<PawPrint size={12} />
|
||||
All vaccinations current
|
||||
All health records current
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Loyalty Card */}
|
||||
{/* Loyalty Card Placeholder */}
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-(--color-accent-dark) mb-3">
|
||||
<Star size={16} />
|
||||
Loyalty Rewards
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-stone-800">{LOYALTY.points} <span className="text-sm font-normal text-stone-500">pts</span></p>
|
||||
<div className="mt-2 bg-stone-100 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-(--color-accent) h-full rounded-full transition-all"
|
||||
style={{ width: `${(LOYALTY.points / LOYALTY.nextRewardAt) * 100}%` }}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<div className="w-16 h-16 rounded-full bg-(--color-accent-light) flex items-center justify-center mb-3">
|
||||
<Star size={32} className="text-(--color-accent)" />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-stone-800">Coming Soon</p>
|
||||
<p className="text-xs text-stone-500 text-center mt-1">
|
||||
Earn points with every visit and redeem for exclusive rewards
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 mt-1">
|
||||
{LOYALTY.nextRewardAt - LOYALTY.points} pts to {LOYALTY.rewardName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outstanding Balance & Recent Activity */}
|
||||
{/* Pending Balance & Recent Activity */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Outstanding Balance */}
|
||||
{outstanding > 0 && (
|
||||
{/* Pending Invoices */}
|
||||
{pendingInvoices.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-stone-500 mb-1">
|
||||
<CreditCard size={16} />
|
||||
Outstanding Balance
|
||||
Pending Invoices
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-stone-800">${outstanding.toFixed(2)}</p>
|
||||
<p className="text-2xl font-bold text-stone-800">
|
||||
{formatCurrency(pendingBalance)}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
@@ -167,29 +352,51 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{pendingInvoices.slice(0, 3).map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="text-stone-600">
|
||||
{invoice.invoiceNumber} - {formatCurrency(invoice.amount)}
|
||||
</span>
|
||||
<span className="text-xs text-stone-400">
|
||||
Due {invoice.dueDate ? formatDate(invoice.dueDate) : formatDate(invoice.date)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<h3 className="text-sm font-medium text-stone-500 mb-3">Recent Activity</h3>
|
||||
<div className="space-y-2.5">
|
||||
{recentEvents.map(evt => (
|
||||
<div key={evt.id} className="flex items-center gap-3 text-sm">
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${evt.type === "payment" ? "bg-green-400" : "bg-(--color-accent)"}`} />
|
||||
<span className="text-stone-600 flex-1">{evt.text}</span>
|
||||
<span className="text-xs text-stone-400">{formatDate(evt.date)}</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Health Alerts */}
|
||||
{healthAlerts.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-3">
|
||||
<AlertTriangle size={16} />
|
||||
Health Alerts
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{healthAlerts.slice(0, 5).map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-3 text-sm">
|
||||
<div className="w-2 h-2 rounded-full shrink-0 bg-amber-400" />
|
||||
<span className="text-stone-600 flex-1">
|
||||
<span className="font-medium">{item.petName}:</span>{" "}
|
||||
{item.alert}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate("pets")}
|
||||
className="flex items-center gap-1 text-sm text-(--color-accent-dark) font-medium mt-3 hover:text-(--color-accent)"
|
||||
>
|
||||
View all <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate("appointments")}
|
||||
className="flex items-center gap-1 text-sm text-(--color-accent-dark) font-medium mt-3 hover:text-(--color-accent)"
|
||||
>
|
||||
View all <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from "react";
|
||||
import { X, Save } from "lucide-react";
|
||||
import type { Pet } from "../mockData.js";
|
||||
|
||||
interface Props {
|
||||
pet?: Pet;
|
||||
onSave: (pet: Pet) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function PetForm({ pet, onSave, onCancel }: Props) {
|
||||
const [name, setName] = useState(pet?.name ?? "");
|
||||
const [breed, setBreed] = useState(pet?.breed ?? "");
|
||||
const [weight, setWeight] = useState(pet?.weight ?? 0);
|
||||
const [notes, setNotes] = useState(pet?.allergies ?? "");
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!pet) return;
|
||||
onSave({ ...pet, name, breed, weight, allergies: notes });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-stone-800">{pet ? "Edit Pet" : "Add Pet"}</h2>
|
||||
<button onClick={onCancel} className="p-2 hover:bg-stone-50 rounded-lg">
|
||||
<X size={16} className="text-stone-400" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-600 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-600 mb-1">Breed</label>
|
||||
<input
|
||||
type="text"
|
||||
value={breed}
|
||||
onChange={e => setBreed(e.target.value)}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-600 mb-1">Weight (lbs)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={weight}
|
||||
onChange={e => setWeight(Number(e.target.value))}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-600 mb-1">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,152 @@
|
||||
import { useState } from "react";
|
||||
import { PawPrint, Heart, Scissors, Syringe, AlertTriangle, CheckCircle, Clock, Upload, Edit3 } from "lucide-react";
|
||||
import { PETS, PAST_APPOINTMENTS } from "../mockData.js";
|
||||
import type { Pet } from "../mockData.js";
|
||||
import { useState, useEffect } from "react";
|
||||
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2 } from "lucide-react";
|
||||
import { PetForm } from "./PetForm.js";
|
||||
|
||||
interface Pet {
|
||||
id: string;
|
||||
name: string;
|
||||
breed: string;
|
||||
weight: number;
|
||||
birthDate: string;
|
||||
photoUrl: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: string;
|
||||
confirmationStatus: string | null;
|
||||
customerNotes: string | null;
|
||||
groomerNotes: string | null;
|
||||
reportCardId: string | null;
|
||||
pet: { id: string; name: string; photo: string | null } | null;
|
||||
service: { id: string } | null;
|
||||
staff: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface AppointmentsResponse {
|
||||
upcoming: Appointment[];
|
||||
past: Appointment[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sessionId: string | null;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
type VaxStatus = "valid" | "expiring" | "expired";
|
||||
const VAX_STATUS_STYLES: Record<VaxStatus, { bg: string; text: string; icon: typeof CheckCircle }> = {
|
||||
valid: { bg: "bg-green-100", text: "text-green-700", icon: CheckCircle },
|
||||
expiring: { bg: "bg-amber-100", text: "text-amber-700", icon: Clock },
|
||||
expired: { bg: "bg-red-100", text: "text-red-700", icon: AlertTriangle },
|
||||
};
|
||||
function buildHeaders(sessionId: string | null): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionId) {
|
||||
headers["X-Impersonation-Session-Id"] = sessionId;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function PetProfiles({ readOnly }: Props) {
|
||||
const [selectedPetId, setSelectedPetId] = useState<string>(PETS[0]?.id ?? "");
|
||||
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "vaccinations" | "history">("info");
|
||||
export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
|
||||
const [selectedPetId, setSelectedPetId] = useState<string>("");
|
||||
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
|
||||
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pet = PETS.find(p => p.id === selectedPetId)!;
|
||||
const petHistory = PAST_APPOINTMENTS.filter(a => a.petId === selectedPetId);
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [petsRes, apptsRes] = await Promise.all([
|
||||
fetch("/api/portal/pets", { headers: buildHeaders(sessionId) }),
|
||||
fetch("/api/portal/appointments", { headers: buildHeaders(sessionId) }),
|
||||
]);
|
||||
|
||||
if (!petsRes.ok) {
|
||||
throw new Error("Failed to load pets");
|
||||
}
|
||||
if (!apptsRes.ok) {
|
||||
throw new Error("Failed to load appointments");
|
||||
}
|
||||
|
||||
const petsData = await petsRes.json();
|
||||
const apptsData: AppointmentsResponse = await apptsRes.json();
|
||||
|
||||
setPets(petsData);
|
||||
setAppointments(apptsData);
|
||||
|
||||
if (petsData.length > 0 && !selectedPetId) {
|
||||
setSelectedPetId(petsData[0].id);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [sessionId]);
|
||||
|
||||
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
|
||||
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
|
||||
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
|
||||
|
||||
function handlePetSave(updatedPet: Pet) {
|
||||
setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p));
|
||||
setEditingPetId(null);
|
||||
}
|
||||
|
||||
if (editingPet) {
|
||||
return (
|
||||
<PetForm
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pet={editingPet as any}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onSave={handlePetSave as any}
|
||||
onCancel={() => setEditingPetId(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-stone-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pets.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-stone-400 text-sm">No pets found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Pet Selector */}
|
||||
<div className="flex gap-3">
|
||||
{PETS.map(p => (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||
{pets.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors ${
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors shrink-0 ${
|
||||
p.id === selectedPetId ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 bg-white hover:border-stone-300"
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{p.photo}</span>
|
||||
<span className="text-2xl">{p.photoUrl ? "🐾" : "🐾"}</span>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-stone-800 text-sm">{p.name}</p>
|
||||
<p className="text-xs text-stone-500">{p.breed}</p>
|
||||
@@ -43,23 +156,31 @@ export function PetProfiles({ readOnly }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Profile Header */}
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-20 h-20 rounded-2xl bg-(--color-accent-light) flex items-center justify-center text-4xl">
|
||||
{pet.photo}
|
||||
{selectedPet && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-20 h-20 rounded-2xl bg-(--color-accent-light) flex items-center justify-center text-4xl overflow-hidden">
|
||||
{selectedPet.photoUrl ? (
|
||||
<img src={selectedPet.photoUrl} alt={selectedPet.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span>🐾</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
|
||||
<p className="text-stone-500 text-sm">{selectedPet.breed} · {selectedPet.weight} lbs</p>
|
||||
<p className="text-stone-400 text-xs mt-0.5">
|
||||
Born {selectedPet.birthDate ? new Date(selectedPet.birthDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button onClick={() => setEditingPetId(selectedPet.id)} className="p-2 hover:bg-stone-50 rounded-lg">
|
||||
<Edit3 size={16} className="text-stone-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-stone-800">{pet.name}</h2>
|
||||
<p className="text-stone-500 text-sm">{pet.breed} · {pet.weight} lbs · {pet.sex === "male" ? "♂" : "♀"} {pet.spayedNeutered ? "(spayed/neutered)" : ""}</p>
|
||||
<p className="text-stone-400 text-xs mt-0.5">Born {new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button className="p-2 hover:bg-stone-50 rounded-lg">
|
||||
<Edit3 size={16} className="text-stone-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
|
||||
@@ -67,7 +188,6 @@ export function PetProfiles({ readOnly }: Props) {
|
||||
{ id: "info", label: "Basic Info", icon: PawPrint },
|
||||
{ id: "medical", label: "Medical", icon: Heart },
|
||||
{ id: "grooming", label: "Grooming", icon: Scissors },
|
||||
{ id: "vaccinations", label: "Vaccinations", icon: Syringe },
|
||||
{ id: "history", label: "History", icon: Clock },
|
||||
] as const).map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
@@ -85,10 +205,9 @@ export function PetProfiles({ readOnly }: Props) {
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
{activeTab === "info" && <BasicInfoTab pet={pet} readOnly={readOnly} />}
|
||||
{activeTab === "medical" && <MedicalTab pet={pet} readOnly={readOnly} />}
|
||||
{activeTab === "grooming" && <GroomingTab pet={pet} readOnly={readOnly} />}
|
||||
{activeTab === "vaccinations" && <VaccinationsTab pet={pet} readOnly={readOnly} />}
|
||||
{activeTab === "info" && selectedPet && <BasicInfoTab pet={selectedPet} readOnly={readOnly} />}
|
||||
{activeTab === "medical" && selectedPet && <MedicalTab pet={selectedPet} readOnly={readOnly} />}
|
||||
{activeTab === "grooming" && selectedPet && <GroomingTab pet={selectedPet} readOnly={readOnly} />}
|
||||
{activeTab === "history" && <HistoryTab petHistory={petHistory} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,11 +227,10 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<InfoRow label="Name" value={pet.name} />
|
||||
<InfoRow label="Breed" value={pet.breed} />
|
||||
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
|
||||
<InfoRow label="Weight" value={`${pet.weight} lbs`} />
|
||||
<InfoRow label="Date of Birth" value={new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} />
|
||||
<InfoRow label="Sex" value={pet.sex === "male" ? "Male" : "Female"} />
|
||||
<InfoRow label="Spayed/Neutered" value={pet.spayedNeutered ? "Yes" : "No"} />
|
||||
<InfoRow label="Date of Birth" value={pet.birthDate ? new Date(pet.birthDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
|
||||
<InfoRow label="Notes" value={pet.notes || "None"} />
|
||||
{!readOnly && (
|
||||
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
|
||||
Upload Photo
|
||||
@@ -125,12 +243,7 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<InfoRow label="Allergies" value={pet.allergies} />
|
||||
<InfoRow label="Skin Conditions" value={pet.skinConditions} />
|
||||
<InfoRow label="Anxiety Triggers" value={pet.anxietyTriggers} />
|
||||
<InfoRow label="Aggression Notes" value={pet.aggressionNotes} />
|
||||
<InfoRow label="Mobility Issues" value={pet.mobilityIssues} />
|
||||
<InfoRow label="Medications" value={pet.medications} />
|
||||
<InfoRow label="Notes" value={pet.notes || "No medical notes on file"} />
|
||||
{!readOnly && (
|
||||
<p className="mt-3 text-xs text-stone-400">
|
||||
Changes to medical notes will be flagged for staff review.
|
||||
@@ -143,10 +256,7 @@ function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<InfoRow label="Preferred Cut" value={pet.preferredCut} />
|
||||
<InfoRow label="Shampoo Preference" value={pet.shampooPreference} />
|
||||
<InfoRow label="Sensitive Areas" value={pet.sensitiveAreas} />
|
||||
<InfoRow label="Standing Instructions" value={pet.standingInstructions} />
|
||||
<InfoRow label="Notes" value={pet.notes || "No grooming notes on file"} />
|
||||
{!readOnly && (
|
||||
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
|
||||
Upload Reference Photo
|
||||
@@ -156,58 +266,7 @@ function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VaccinationsTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
|
||||
<th className="pb-2 font-medium">Vaccine</th>
|
||||
<th className="pb-2 font-medium">Administered</th>
|
||||
<th className="pb-2 font-medium">Expires</th>
|
||||
<th className="pb-2 font-medium">Status</th>
|
||||
<th className="pb-2 font-medium">Proof</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pet.vaccinations.map(vax => {
|
||||
const style = VAX_STATUS_STYLES[vax.status];
|
||||
const StatusIcon = style.icon;
|
||||
return (
|
||||
<tr key={vax.name} className="border-b border-stone-50">
|
||||
<td className="py-2.5 font-medium text-stone-800">{vax.name}</td>
|
||||
<td className="py-2.5 text-stone-600">{new Date(vax.lastAdministered).toLocaleDateString()}</td>
|
||||
<td className="py-2.5 text-stone-600">{new Date(vax.expirationDate).toLocaleDateString()}</td>
|
||||
<td className="py-2.5">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${style.bg} ${style.text}`}>
|
||||
<StatusIcon size={12} />
|
||||
{vax.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
{vax.documentUploaded ? (
|
||||
<span className="text-green-600 text-xs">Uploaded</span>
|
||||
) : !readOnly ? (
|
||||
<button className="flex items-center gap-1 text-xs text-(--color-accent-dark) hover:underline">
|
||||
<Upload size={12} />
|
||||
Upload
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-stone-400 text-xs">Missing</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
|
||||
function HistoryTab({ petHistory }: { petHistory: Appointment[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{petHistory.length === 0 ? (
|
||||
@@ -219,14 +278,18 @@ function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
|
||||
<Scissors size={14} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-stone-800">{appt.services.join(", ")}</p>
|
||||
<p className="text-xs text-stone-500">with {appt.groomerName} · ${appt.price}</p>
|
||||
<p className="text-sm font-medium text-stone-800">
|
||||
{appt.service ? "Grooming Service" : "Appointment"}
|
||||
</p>
|
||||
<p className="text-xs text-stone-500">
|
||||
with {appt.staff?.name || "Unknown Groomer"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-stone-400">
|
||||
{new Date(appt.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
{new Date(appt.startTime).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
</span>
|
||||
{appt.reportCardId && (
|
||||
<span className="text-xs text-(--color-accent-dark) font-medium">Report →</span>
|
||||
<span className="text-xs text-(--color-accent-dark) font-medium">Report</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { FileText, Share2, Calendar, Smile, Meh, AlertCircle, ChevronRight } from "lucide-react";
|
||||
import { REPORT_CARDS } from "../mockData.js";
|
||||
import type { ReportCard } from "../mockData.js";
|
||||
import { useState, useEffect } from "react";
|
||||
import { FileText, Share2, Calendar, Smile, Meh, ChevronRight, Loader2 } from "lucide-react";
|
||||
|
||||
type MoodKey = "calm" | "cooperative" | "anxious" | "wiggly";
|
||||
|
||||
const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: string; bg: string }> = {
|
||||
calm: { icon: Smile, label: "Calm & Relaxed", color: "text-green-700", bg: "bg-green-100" },
|
||||
cooperative: { icon: Smile, label: "Cooperative", color: "text-blue-700", bg: "bg-blue-100" },
|
||||
@@ -11,8 +10,87 @@ const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: s
|
||||
wiggly: { icon: Meh, label: "Wiggly", color: "text-purple-700", bg: "bg-purple-100" },
|
||||
};
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
petId: string;
|
||||
serviceId: string;
|
||||
groomerId: string | null;
|
||||
date: string;
|
||||
time: string;
|
||||
status: string;
|
||||
petName?: string;
|
||||
serviceName?: string;
|
||||
groomerName?: string;
|
||||
reportCardId?: string;
|
||||
}
|
||||
|
||||
export function ReportCards() {
|
||||
const [selectedCard, setSelectedCard] = useState<ReportCard | null>(null);
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedCard, setSelectedCard] = useState<Appointment | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchReportCards = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/portal/appointments");
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const allAppointments: Appointment[] = data.appointments || data || [];
|
||||
const reportCardAppointments = allAppointments.filter(
|
||||
(appt) => appt.reportCardId
|
||||
);
|
||||
setAppointments(reportCardAppointments);
|
||||
} else {
|
||||
setError("Failed to load report cards.");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load report cards. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchReportCards();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-stone-400" size={24} />
|
||||
<span className="ml-3 text-stone-500">Loading report cards...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-stone-100 text-stone-700 rounded-md hover:bg-stone-200"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (appointments.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-stone-100 flex items-center justify-center">
|
||||
<FileText size={24} className="text-stone-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-stone-800 mb-1">No Report Cards Yet</h3>
|
||||
<p className="text-sm text-stone-500">
|
||||
Report cards from your grooming visits will appear here after your appointments.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedCard) {
|
||||
return <ReportCardDetail card={selectedCard} onBack={() => setSelectedCard(null)} />;
|
||||
@@ -23,8 +101,9 @@ export function ReportCards() {
|
||||
<p className="text-sm text-stone-500">Grooming report cards from your recent visits</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{REPORT_CARDS.map(card => {
|
||||
const mood = MOOD_CONFIG[card.behaviorMood];
|
||||
{appointments.map((card) => {
|
||||
const moodKey: MoodKey = "cooperative";
|
||||
const mood = MOOD_CONFIG[moodKey];
|
||||
const MoodIcon = mood.icon;
|
||||
return (
|
||||
<button
|
||||
@@ -38,16 +117,20 @@ export function ReportCards() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-stone-800">{card.petName}'s Report Card</h3>
|
||||
<h3 className="font-semibold text-stone-800">{card.petName || "Pet"}'s Report Card</h3>
|
||||
<ChevronRight size={16} className="text-stone-400" />
|
||||
</div>
|
||||
<p className="text-sm text-stone-500 mt-0.5">
|
||||
{card.servicesPerformed.join(", ")} with {card.groomerName}
|
||||
{card.serviceName || "Grooming"} with {card.groomerName || "your groomer"}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<span className="flex items-center gap-1 text-xs text-stone-400">
|
||||
<Calendar size={12} />
|
||||
{new Date(card.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
{new Date(card.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${mood.bg} ${mood.color}`}>
|
||||
<MoodIcon size={12} />
|
||||
@@ -64,28 +147,40 @@ export function ReportCards() {
|
||||
);
|
||||
}
|
||||
|
||||
function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => void }) {
|
||||
const mood = MOOD_CONFIG[card.behaviorMood];
|
||||
function ReportCardDetail({ card, onBack }: { card: Appointment; onBack: () => void }) {
|
||||
const moodKey: MoodKey = "cooperative";
|
||||
const mood = MOOD_CONFIG[moodKey];
|
||||
const MoodIcon = mood.icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<button onClick={onBack} className="text-sm text-(--color-accent-dark) font-medium hover:underline">
|
||||
← Back to Report Cards
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-(--color-accent-dark) font-medium hover:underline"
|
||||
>
|
||||
Back to Report Cards
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-(--color-accent-lighter) to-(--color-accent-light) p-6">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="text-xl font-semibold text-stone-800">{card.petName}'s Grooming Report</h2>
|
||||
<h2 className="text-xl font-semibold text-stone-800">
|
||||
{card.petName || "Pet"}'s Grooming Report
|
||||
</h2>
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-white/80 text-stone-700 rounded-lg text-sm font-medium hover:bg-white">
|
||||
<Share2 size={14} />
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-stone-600">
|
||||
{new Date(card.date).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })} · Groomer: {card.groomerName}
|
||||
{new Date(card.date).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
{card.groomerName ? ` · Groomer: ${card.groomerName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -99,14 +194,14 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
|
||||
<div className="w-full h-32 bg-stone-200 rounded-lg flex items-center justify-center text-stone-400 text-sm mb-2">
|
||||
Photo placeholder
|
||||
</div>
|
||||
<p className="text-sm text-stone-600">{card.beforeDescription}</p>
|
||||
<p className="text-sm text-stone-600">Before photo description not available.</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-(--color-accent-lighter) p-4">
|
||||
<p className="text-xs font-medium text-(--color-accent) uppercase mb-2">After</p>
|
||||
<div className="w-full h-32 bg-(--color-accent-light) rounded-lg flex items-center justify-center text-(--color-accent) text-sm mb-2">
|
||||
Photo placeholder
|
||||
</div>
|
||||
<p className="text-sm text-stone-700">{card.afterDescription}</p>
|
||||
<p className="text-sm text-stone-700">After photo description not available.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,11 +210,9 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
|
||||
<div>
|
||||
<h3 className="font-medium text-stone-800 mb-2">Services Performed</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{card.servicesPerformed.map(s => (
|
||||
<span key={s} className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
<span className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
|
||||
{card.serviceName || "Grooming"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -132,36 +225,32 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Condition Observations */}
|
||||
{card.conditionObservations.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium text-stone-800 mb-2">Condition Observations</h3>
|
||||
<div className="space-y-2">
|
||||
{card.conditionObservations.map((obs, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<AlertCircle size={16} className="text-amber-500 mt-0.5 shrink-0" />
|
||||
<span className="text-stone-700">{obs}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groomer's Note */}
|
||||
<div className="bg-(--color-accent-lighter) rounded-xl p-4">
|
||||
<h3 className="font-medium text-stone-800 mb-2">A Note from {card.groomerName}</h3>
|
||||
<p className="text-sm text-stone-700 italic leading-relaxed">"{card.groomerNote}"</p>
|
||||
<h3 className="font-medium text-stone-800 mb-2">
|
||||
A Note from {card.groomerName || "Your Groomer"}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-700 italic leading-relaxed">
|
||||
"Report card details are not yet available. Please check back after your visit."
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Next Appointment CTA */}
|
||||
<div className="bg-white border border-stone-200 rounded-xl p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-800">Next recommended visit</p>
|
||||
<p className="text-xs text-stone-500">
|
||||
{new Date(card.nextRecommendedDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-stone-800">Book your next visit</p>
|
||||
<p className="text-xs text-stone-500">Schedule your next grooming appointment</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Pre-select the service from report card (serviceId/serviceName) once BookPage supports service pre-selection via URL param
|
||||
const params = new URLSearchParams();
|
||||
if (card.petName) params.set("petName", card.petName);
|
||||
if (card.serviceName) params.set("serviceName", card.serviceName);
|
||||
window.location.href = `/admin/book${params.size > 0 ? `?${params.toString()}` : ""}`;
|
||||
}}
|
||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
Rebook Now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -7,7 +7,9 @@
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: v2
|
||||
name: groombook
|
||||
description: Open source pet grooming business management & CRM
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "2026.03.19-ea54506"
|
||||
home: https://groombook.github.io
|
||||
sources:
|
||||
- https://github.com/groombook/groombook
|
||||
maintainers:
|
||||
- name: GroomBook
|
||||
url: https://github.com/groombook
|
||||
keywords:
|
||||
- groombook
|
||||
- pet-grooming
|
||||
- scheduling
|
||||
- crm
|
||||
@@ -0,0 +1,121 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "groombook.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "groombook.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "groombook.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "groombook.labels" -}}
|
||||
helm.sh/chart: {{ include "groombook.chart" . }}
|
||||
{{ include "groombook.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "groombook.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "groombook.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Component labels (extends common labels with component name)
|
||||
*/}}
|
||||
{{- define "groombook.componentLabels" -}}
|
||||
{{ include "groombook.labels" . }}
|
||||
app.kubernetes.io/component: {{ .component }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Component selector labels
|
||||
*/}}
|
||||
{{- define "groombook.componentSelectorLabels" -}}
|
||||
{{ include "groombook.selectorLabels" . }}
|
||||
app.kubernetes.io/component: {{ .component }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Service account name
|
||||
*/}}
|
||||
{{- define "groombook.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "groombook.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
API image reference
|
||||
*/}}
|
||||
{{- define "groombook.apiImage" -}}
|
||||
{{- printf "%s:%s" .Values.api.image.repository (default .Chart.AppVersion .Values.api.image.tag) }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Web image reference
|
||||
*/}}
|
||||
{{- define "groombook.webImage" -}}
|
||||
{{- printf "%s:%s" .Values.web.image.repository (default .Chart.AppVersion .Values.web.image.tag) }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Migrate image reference
|
||||
*/}}
|
||||
{{- define "groombook.migrateImage" -}}
|
||||
{{- printf "%s:%s" .Values.migrate.image.repository (default .Chart.AppVersion .Values.migrate.image.tag) }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Database URL — differs by postgresql.mode
|
||||
Integrated: construct from chart-managed PostgreSQL credentials
|
||||
Operator: read from credentialsSecret
|
||||
*/}}
|
||||
{{- define "groombook.databaseSecretName" -}}
|
||||
{{- if eq .Values.postgresql.mode "operator" }}
|
||||
{{- required "postgresql.operator.credentialsSecret is required in operator mode" .Values.postgresql.operator.credentialsSecret }}
|
||||
{{- else }}
|
||||
{{- include "groombook.fullname" . }}-db-credentials
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Database URL secret key
|
||||
*/}}
|
||||
{{- define "groombook.databaseSecretKey" -}}
|
||||
{{- if eq .Values.postgresql.mode "operator" -}}
|
||||
uri
|
||||
{{- else -}}
|
||||
database-url
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,71 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}-api
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
replicas: {{ .Values.api.replicas }}
|
||||
progressDeadlineSeconds: 300
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "groombook.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "groombook.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "groombook.serviceAccountName" . }}
|
||||
containers:
|
||||
- name: api
|
||||
image: {{ include "groombook.apiImage" . }}
|
||||
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
name: http
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: PORT
|
||||
value: {{ .Values.api.env.port | quote }}
|
||||
- name: NODE_ENV
|
||||
value: {{ .Values.api.env.nodeEnv | quote }}
|
||||
- name: AUTH_DISABLED
|
||||
value: {{ .Values.api.env.authDisabled | quote }}
|
||||
{{- if .Values.api.env.corsOrigin }}
|
||||
- name: CORS_ORIGIN
|
||||
value: {{ .Values.api.env.corsOrigin | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.api.env.oidcIssuer }}
|
||||
- name: OIDC_ISSUER
|
||||
value: {{ .Values.api.env.oidcIssuer | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.api.env.oidcAudience }}
|
||||
- name: OIDC_AUDIENCE
|
||||
value: {{ .Values.api.env.oidcAudience | quote }}
|
||||
{{- end }}
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "groombook.databaseSecretName" . }}
|
||||
key: {{ include "groombook.databaseSecretKey" . }}
|
||||
resources:
|
||||
{{- toYaml .Values.api.resources | nindent 12 }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}-api
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
type: {{ .Values.api.service.type }}
|
||||
selector:
|
||||
{{- include "groombook.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: api
|
||||
ports:
|
||||
- port: {{ .Values.api.service.port }}
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
@@ -0,0 +1,28 @@
|
||||
{{- if eq .Values.postgresql.mode "operator" }}
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}-postgres
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
spec:
|
||||
instances: {{ .Values.postgresql.operator.instances }}
|
||||
storage:
|
||||
size: {{ .Values.postgresql.operator.storage.size }}
|
||||
{{- if .Values.postgresql.operator.storage.storageClass }}
|
||||
storageClass: {{ .Values.postgresql.operator.storage.storageClass }}
|
||||
{{- end }}
|
||||
bootstrap:
|
||||
initdb:
|
||||
database: {{ .Values.postgresql.operator.bootstrap.database }}
|
||||
owner: {{ .Values.postgresql.operator.bootstrap.owner }}
|
||||
{{- if .Values.postgresql.operator.credentialsSecret }}
|
||||
secret:
|
||||
name: {{ .Values.postgresql.operator.credentialsSecret }}
|
||||
{{- end }}
|
||||
{{- if .Values.postgresql.operator.monitoring.enabled }}
|
||||
monitoring:
|
||||
enablePodMonitor: true
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{{- if eq .Values.postgresql.mode "integrated" }}
|
||||
{{- $password := default (randAlphaNum 16) .Values.postgresql.integrated.auth.password }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}-db-credentials
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
postgresql-password: {{ $password | quote }}
|
||||
database-url: {{ printf "postgres://%s:%s@%s-postgresql:5432/%s" .Values.postgresql.integrated.auth.username $password (include "groombook.fullname" .) .Values.postgresql.integrated.auth.database | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{- if and .Values.dragonfly.enabled (eq .Values.dragonfly.mode "operator") }}
|
||||
apiVersion: dragonflydb.io/v1alpha1
|
||||
kind: Dragonfly
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}-dragonfly
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.dragonfly.operator.replicas }}
|
||||
{{- with .Values.dragonfly.operator.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{{- if and .Values.dragonfly.enabled (eq .Values.dragonfly.mode "integrated") }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}-dragonfly
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: dragonfly
|
||||
spec:
|
||||
type: {{ .Values.dragonfly.integrated.service.type }}
|
||||
selector:
|
||||
{{- include "groombook.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: dragonfly
|
||||
ports:
|
||||
- port: {{ .Values.dragonfly.integrated.service.port }}
|
||||
targetPort: 6379
|
||||
protocol: TCP
|
||||
name: redis
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{{- if and .Values.dragonfly.enabled (eq .Values.dragonfly.mode "integrated") }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}-dragonfly
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: dragonfly
|
||||
spec:
|
||||
serviceName: {{ include "groombook.fullname" . }}-dragonfly
|
||||
replicas: {{ .Values.dragonfly.integrated.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "groombook.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: dragonfly
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "groombook.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: dragonfly
|
||||
spec:
|
||||
containers:
|
||||
- name: dragonfly
|
||||
image: {{ printf "%s:%s" .Values.dragonfly.integrated.image.repository .Values.dragonfly.integrated.image.tag }}
|
||||
imagePullPolicy: {{ .Values.dragonfly.integrated.image.pullPolicy }}
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
name: redis
|
||||
protocol: TCP
|
||||
resources:
|
||||
{{- toYaml .Values.dragonfly.integrated.resources | nindent 12 }}
|
||||
{{- if .Values.dragonfly.integrated.storage.size }}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
{{- if .Values.dragonfly.integrated.storage.storageClass }}
|
||||
storageClassName: {{ .Values.dragonfly.integrated.storage.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.dragonfly.integrated.storage.size }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
{{- if eq .service "api" }}
|
||||
name: {{ include "groombook.fullname" $ }}-api
|
||||
port:
|
||||
number: {{ $.Values.api.service.port }}
|
||||
{{- else }}
|
||||
name: {{ include "groombook.fullname" $ }}-web
|
||||
port:
|
||||
number: {{ $.Values.web.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{{- if .Values.migrate.enabled }}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}-migrate
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: migrate
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install,pre-upgrade
|
||||
"helm.sh/hook-weight": "1"
|
||||
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
|
||||
spec:
|
||||
backoffLimit: {{ .Values.migrate.backoffLimit }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "groombook.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: migrate
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: migrate
|
||||
image: {{ include "groombook.migrateImage" . }}
|
||||
imagePullPolicy: {{ .Values.migrate.image.pullPolicy }}
|
||||
command: ["pnpm", "db:migrate"]
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "groombook.databaseSecretName" . }}
|
||||
key: {{ include "groombook.databaseSecretKey" . }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{{- if eq .Values.postgresql.mode "integrated" }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}-postgresql
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgresql
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
{{- include "groombook.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgresql
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
protocol: TCP
|
||||
name: postgresql
|
||||
{{- end }}
|
||||
@@ -0,0 +1,72 @@
|
||||
{{- if eq .Values.postgresql.mode "integrated" }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}-postgresql
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgresql
|
||||
spec:
|
||||
serviceName: {{ include "groombook.fullname" . }}-postgresql
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "groombook.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: postgresql
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "groombook.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: postgresql
|
||||
spec:
|
||||
containers:
|
||||
- name: postgresql
|
||||
image: {{ .Values.postgresql.integrated.image }}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: postgresql
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
value: {{ .Values.postgresql.integrated.auth.database | quote }}
|
||||
- name: POSTGRES_USER
|
||||
value: {{ .Values.postgresql.integrated.auth.username | quote }}
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "groombook.fullname" . }}-db-credentials
|
||||
key: postgresql-password
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- {{ .Values.postgresql.integrated.auth.username | quote }}
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- {{ .Values.postgresql.integrated.auth.username | quote }}
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
{{- if .Values.postgresql.integrated.storage.storageClass }}
|
||||
storageClassName: {{ .Values.postgresql.integrated.storage.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.postgresql.integrated.storage.size }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "groombook.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}-web
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
replicas: {{ .Values.web.replicas }}
|
||||
progressDeadlineSeconds: 300
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "groombook.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "groombook.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "groombook.serviceAccountName" . }}
|
||||
containers:
|
||||
- name: web
|
||||
image: {{ include "groombook.webImage" . }}
|
||||
imagePullPolicy: {{ .Values.web.image.pullPolicy }}
|
||||
ports:
|
||||
- containerPort: 80
|
||||
name: http
|
||||
protocol: TCP
|
||||
resources:
|
||||
{{- toYaml .Values.web.resources | nindent 12 }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "groombook.fullname" . }}-web
|
||||
labels:
|
||||
{{- include "groombook.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
type: {{ .Values.web.service.type }}
|
||||
selector:
|
||||
{{- include "groombook.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
ports:
|
||||
- port: {{ .Values.web.service.port }}
|
||||
targetPort: 80
|
||||
protocol: TCP
|
||||
name: http
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# -- API deployment
|
||||
api:
|
||||
image:
|
||||
repository: ghcr.io/groombook/api
|
||||
tag: "" # defaults to chart appVersion
|
||||
pullPolicy: IfNotPresent
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
env:
|
||||
nodeEnv: production
|
||||
authDisabled: false
|
||||
corsOrigin: ""
|
||||
oidcIssuer: ""
|
||||
oidcAudience: groombook
|
||||
port: "3000"
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
|
||||
# -- Web deployment (nginx)
|
||||
web:
|
||||
image:
|
||||
repository: ghcr.io/groombook/web
|
||||
tag: "" # defaults to chart appVersion
|
||||
pullPolicy: IfNotPresent
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 128Mi
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
||||
# -- Database migration job (runs as pre-install/pre-upgrade hook)
|
||||
migrate:
|
||||
enabled: true
|
||||
image:
|
||||
repository: ghcr.io/groombook/api
|
||||
tag: "" # same image as api
|
||||
pullPolicy: IfNotPresent
|
||||
backoffLimit: 3
|
||||
|
||||
# -- PostgreSQL configuration
|
||||
postgresql:
|
||||
# Choose deployment mode: 'integrated' deploys a native PostgreSQL StatefulSet, 'operator' creates a CNPG Cluster CR
|
||||
mode: integrated
|
||||
integrated:
|
||||
image: postgres:16
|
||||
storage:
|
||||
size: 10Gi
|
||||
storageClass: ""
|
||||
auth:
|
||||
database: groombook
|
||||
username: groombook
|
||||
password: "" # auto-generated if empty
|
||||
existingSecret: ""
|
||||
operator:
|
||||
instances: 3
|
||||
storage:
|
||||
size: 10Gi
|
||||
storageClass: ""
|
||||
bootstrap:
|
||||
database: groombook
|
||||
owner: groombook
|
||||
credentialsSecret: "" # must pre-exist with key 'uri'
|
||||
monitoring:
|
||||
enabled: true
|
||||
|
||||
# -- DragonflyDB (cache/pub-sub) — optional, disabled by default
|
||||
dragonfly:
|
||||
enabled: false
|
||||
# Choose deployment mode: 'integrated' deploys a StatefulSet, 'operator' creates a Dragonfly CR
|
||||
mode: integrated
|
||||
integrated:
|
||||
image:
|
||||
repository: docker.dragonflydb.io/dragonflydb/dragonfly
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
storage:
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 6379
|
||||
operator:
|
||||
replicas: 1
|
||||
resources: {}
|
||||
|
||||
# -- Ingress configuration
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: groombook.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
service: web
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
service: api
|
||||
tls: []
|
||||
|
||||
# -- Service account
|
||||
serviceAccount:
|
||||
create: true
|
||||
name: ""
|
||||
annotations: {}
|
||||
|
||||
# -- Global image pull secrets
|
||||
imagePullSecrets: []
|
||||
|
||||
# -- Override chart name
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
+2
-1
@@ -17,5 +17,6 @@
|
||||
"node": ">=20",
|
||||
"pnpm": ">=9"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.4"
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Better-Auth required tables for session-based authentication
|
||||
CREATE TABLE "user" (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
image TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE "session" (
|
||||
id TEXT PRIMARY KEY,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE "account" (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
id_token TEXT,
|
||||
access_token_expires_at TIMESTAMPTZ,
|
||||
refresh_token_expires_at TIMESTAMPTZ,
|
||||
scope TEXT,
|
||||
password TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE "verification" (
|
||||
id TEXT PRIMARY KEY,
|
||||
identifier TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Link staff records to auth identity
|
||||
ALTER TABLE staff ADD COLUMN user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Backfill staff.user_id for staff records created before Better-Auth integration.
|
||||
-- Staff records that predate this migration have user_id = NULL; the resolveStaffMiddleware
|
||||
-- now falls back to staff.id (dev mode) and oidcSub (production) so these records still work.
|
||||
-- This migration populates user_id for the known demo/dev staff seeded by seed.ts.
|
||||
|
||||
-- Create demo Better-Auth users for seeded staff (these match the ba-user-* IDs used in tests)
|
||||
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES ('ba-user-manager', 'Demo Manager', 'demo-manager@groombook.dev', true, NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Link the demo manager staff record to the Better-Auth user
|
||||
UPDATE staff
|
||||
SET user_id = 'ba-user-manager', updated_at = NOW()
|
||||
WHERE oidc_sub = 'demo-manager-001' AND user_id IS NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,62 @@
|
||||
"when": 1742587200000,
|
||||
"tag": "0011_impersonation_indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1774080000000,
|
||||
"tag": "0012_pet_photo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1774166400000,
|
||||
"tag": "0013_appointment_confirmation",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1774252800000,
|
||||
"tag": "0014_customer_notes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1774339200000,
|
||||
"tag": "0015_waitlist",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1774425600000,
|
||||
"tag": "0016_ical_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1774512000000,
|
||||
"tag": "0017_better_auth_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1774598400000,
|
||||
"tag": "0018_backfill_staff_user_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1774729055924,
|
||||
"tag": "0019_concerned_sunfire",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -33,5 +33,6 @@
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -50,7 +50,9 @@ export function buildStaff(overrides: Partial<StaffRow> = {}): StaffRow {
|
||||
name: `Staff Member ${id}`,
|
||||
email: `${id}@groombook.test`,
|
||||
oidcSub: `oidc-${id}`,
|
||||
userId: null,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
|
||||
@@ -3,7 +3,7 @@ import postgres from "postgres";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export * from "./schema.js";
|
||||
export { and, asc, desc, eq, exists, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
|
||||
@@ -48,6 +48,58 @@ export const clientStatusEnum = pgEnum("client_status", [
|
||||
"disabled",
|
||||
]);
|
||||
|
||||
// ─── Better-Auth Tables ──────────────────────────────────────────────────────
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").notNull().default(false),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const session = pgTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const account = pgTable("account", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const verification = pgTable("verification", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const clients = pgTable("clients", {
|
||||
@@ -104,7 +156,11 @@ export const staff = pgTable("staff", {
|
||||
email: text("email").notNull().unique(),
|
||||
// oidcSub links to the Authentik OIDC subject claim
|
||||
oidcSub: text("oidc_sub").unique(),
|
||||
// Better-Auth user ID — links staff business record to auth identity
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
|
||||
role: staffRoleEnum("role").notNull().default("groomer"),
|
||||
// Super users bypass appointment-booking restrictions and access admin panels
|
||||
isSuperUser: boolean("is_super_user").notNull().default(false),
|
||||
active: boolean("active").notNull().default(true),
|
||||
// Token for iCal calendar feed subscription (no auth required)
|
||||
icalToken: text("ical_token").unique(),
|
||||
|
||||
+65
-25
@@ -287,6 +287,7 @@ async function seedKnownUsers() {
|
||||
email: "demo-manager@groombook.dev",
|
||||
oidcSub: "demo-manager-001",
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
});
|
||||
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
|
||||
@@ -384,35 +385,41 @@ 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 },
|
||||
{ id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const, isSuperUser: true },
|
||||
];
|
||||
|
||||
const receptionistStaff = [
|
||||
{ id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const },
|
||||
{ id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const, isSuperUser: false },
|
||||
];
|
||||
|
||||
const groomers = [
|
||||
{ id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const },
|
||||
{ id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const },
|
||||
{ id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const },
|
||||
{ id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
||||
{ id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
||||
{ id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
||||
];
|
||||
|
||||
// Bathers are groomers by role but serve as the secondary staff (bather) on appointments
|
||||
const bathers = [
|
||||
{ id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const },
|
||||
{ id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const },
|
||||
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const },
|
||||
{ id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
||||
{ id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
||||
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const, isSuperUser: false },
|
||||
];
|
||||
|
||||
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
|
||||
for (const s of allStaff) {
|
||||
await db.insert(schema.staff).values({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
email: s.email,
|
||||
role: s.role,
|
||||
active: true,
|
||||
});
|
||||
await db.insert(schema.staff)
|
||||
.values({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
email: s.email,
|
||||
role: s.role,
|
||||
isSuperUser: s.isSuperUser,
|
||||
active: true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.staff.email,
|
||||
set: { name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true },
|
||||
});
|
||||
}
|
||||
console.log(`✓ Created ${allStaff.length} staff (1 manager, 1 receptionist, 3 groomers, 3 bathers)`);
|
||||
|
||||
@@ -421,14 +428,19 @@ async function seed() {
|
||||
for (const s of servicesDef) {
|
||||
const id = uuid();
|
||||
serviceIds.push(id);
|
||||
await db.insert(schema.services).values({
|
||||
id,
|
||||
name: s.name,
|
||||
description: s.desc,
|
||||
basePriceCents: s.price,
|
||||
durationMinutes: s.dur,
|
||||
active: true,
|
||||
});
|
||||
await db.insert(schema.services)
|
||||
.values({
|
||||
id,
|
||||
name: s.name,
|
||||
description: s.desc,
|
||||
basePriceCents: s.price,
|
||||
durationMinutes: s.dur,
|
||||
active: true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.services.id,
|
||||
set: { name: s.name, description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
|
||||
});
|
||||
}
|
||||
console.log(`✓ Created ${servicesDef.length} services`);
|
||||
|
||||
@@ -500,8 +512,36 @@ async function seed() {
|
||||
}
|
||||
}
|
||||
|
||||
await db.insert(schema.clients).values(clientBatch);
|
||||
await db.insert(schema.pets).values(petBatch);
|
||||
for (const client of clientBatch) {
|
||||
await db.insert(schema.clients)
|
||||
.values(client)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.clients.email,
|
||||
set: { name: client.name, phone: client.phone, address: client.address, notes: client.notes, emailOptOut: client.emailOptOut },
|
||||
});
|
||||
}
|
||||
|
||||
for (const pet of petBatch) {
|
||||
await db.insert(schema.pets)
|
||||
.values(pet)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.pets.id,
|
||||
set: {
|
||||
clientId: pet.clientId,
|
||||
name: pet.name,
|
||||
species: pet.species,
|
||||
breed: pet.breed,
|
||||
weightKg: pet.weightKg,
|
||||
dateOfBirth: pet.dateOfBirth,
|
||||
healthAlerts: pet.healthAlerts,
|
||||
groomingNotes: pet.groomingNotes,
|
||||
cutStyle: pet.cutStyle,
|
||||
shampooPreference: pet.shampooPreference,
|
||||
specialCareNotes: pet.specialCareNotes,
|
||||
customFields: pet.customFields,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ Created 500 clients with ${petRecords.length} pets`);
|
||||
|
||||
@@ -17,5 +17,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface Staff {
|
||||
name: string;
|
||||
email: string;
|
||||
role: "groomer" | "receptionist" | "manager";
|
||||
isSuperUser: boolean;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
Generated
+304
-38
@@ -26,26 +26,23 @@ importers:
|
||||
specifier: ^1.13.7
|
||||
version: 1.19.11(hono@4.12.8)
|
||||
'@hono/zod-validator':
|
||||
specifier: ^0.4.3
|
||||
version: 0.4.3(hono@4.12.8)(zod@3.25.76)
|
||||
specifier: ^0.7.6
|
||||
version: 0.7.6(hono@4.12.8)(zod@4.3.6)
|
||||
better-auth:
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||
hono:
|
||||
specifier: ^4.6.17
|
||||
version: 4.12.8
|
||||
jose:
|
||||
specifier: ^5.9.6
|
||||
version: 5.10.0
|
||||
node-cron:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
nodemailer:
|
||||
specifier: ^6.9.16
|
||||
version: 6.10.1
|
||||
openid-client:
|
||||
specifier: ^6.1.7
|
||||
version: 6.8.2
|
||||
zod:
|
||||
specifier: ^3.24.1
|
||||
version: 3.25.76
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.10.7
|
||||
@@ -89,6 +86,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||
better-auth:
|
||||
specifier: ^1.0.0
|
||||
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(react@19.2.4)
|
||||
@@ -155,7 +155,7 @@ importers:
|
||||
dependencies:
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.4
|
||||
version: 0.38.4(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4)
|
||||
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/react@19.2.14)(kysely@0.28.14)(postgres@3.4.8)(react@19.2.4)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.8
|
||||
@@ -875,6 +875,81 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@better-auth/core@1.5.6':
|
||||
resolution: {integrity: sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw==}
|
||||
peerDependencies:
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@cloudflare/workers-types': '>=4'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
better-call: 1.3.2
|
||||
jose: ^6.1.0
|
||||
kysely: ^0.28.5
|
||||
nanostores: ^1.0.1
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
|
||||
'@better-auth/drizzle-adapter@1.5.6':
|
||||
resolution: {integrity: sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.5.6
|
||||
'@better-auth/utils': ^0.3.0
|
||||
drizzle-orm: '>=0.41.0'
|
||||
peerDependenciesMeta:
|
||||
drizzle-orm:
|
||||
optional: true
|
||||
|
||||
'@better-auth/kysely-adapter@1.5.6':
|
||||
resolution: {integrity: sha512-Fnf+h8WVKtw6lEOmVmiVVzDf3shJtM60AYf9XTnbdCeUd6MxN/KnaJZpkgtYnRs7a+nwtkVB+fg4lGETebGFXQ==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.5.6
|
||||
'@better-auth/utils': ^0.3.0
|
||||
kysely: ^0.27.0 || ^0.28.0
|
||||
peerDependenciesMeta:
|
||||
kysely:
|
||||
optional: true
|
||||
|
||||
'@better-auth/memory-adapter@1.5.6':
|
||||
resolution: {integrity: sha512-rS7ZsrIl5uvloUgNN0u9LOZJMMXnsZXVdUZ3MrTBKWM2KpoJjzPr9yN3Szyma5+0V7SltnzSGHPkYj2bEzzmlA==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.5.6
|
||||
'@better-auth/utils': ^0.3.0
|
||||
|
||||
'@better-auth/mongo-adapter@1.5.6':
|
||||
resolution: {integrity: sha512-6+M3MS2mor8fTUV3EI1FBLP0cs6QfbN+Ovx9+XxR/GdfKIBoNFzmPEPRbdGt+ft6PvrITsUm+T70+kkHgVSP6w==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.5.6
|
||||
'@better-auth/utils': ^0.3.0
|
||||
mongodb: ^6.0.0 || ^7.0.0
|
||||
peerDependenciesMeta:
|
||||
mongodb:
|
||||
optional: true
|
||||
|
||||
'@better-auth/prisma-adapter@1.5.6':
|
||||
resolution: {integrity: sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.5.6
|
||||
'@better-auth/utils': ^0.3.0
|
||||
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
peerDependenciesMeta:
|
||||
'@prisma/client':
|
||||
optional: true
|
||||
prisma:
|
||||
optional: true
|
||||
|
||||
'@better-auth/telemetry@1.5.6':
|
||||
resolution: {integrity: sha512-yXC7NSxnIFlxDkGdpD7KA+J9nqIQAPCJKe77GoaC5bWoe/DALo1MYorZfTgOafS7wrslNtsPT4feV/LJi1ubqQ==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.5.6
|
||||
|
||||
'@better-auth/utils@0.3.1':
|
||||
resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==}
|
||||
|
||||
'@better-fetch/fetch@1.1.21':
|
||||
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1540,11 +1615,11 @@ packages:
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@hono/zod-validator@0.4.3':
|
||||
resolution: {integrity: sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ==}
|
||||
'@hono/zod-validator@0.7.6':
|
||||
resolution: {integrity: sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==}
|
||||
peerDependencies:
|
||||
hono: '>=3.9.0'
|
||||
zod: ^3.19.1
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
@@ -1593,6 +1668,22 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@noble/ciphers@2.1.1':
|
||||
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/hashes@2.0.1':
|
||||
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@opentelemetry/api@1.9.1':
|
||||
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.40.0':
|
||||
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@petamoriken/float16@3.9.3':
|
||||
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
||||
|
||||
@@ -2430,6 +2521,76 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
better-auth@1.5.6:
|
||||
resolution: {integrity: sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==}
|
||||
peerDependencies:
|
||||
'@lynx-js/react': '*'
|
||||
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
'@sveltejs/kit': ^2.0.0
|
||||
'@tanstack/react-start': ^1.0.0
|
||||
'@tanstack/solid-start': ^1.0.0
|
||||
better-sqlite3: ^12.0.0
|
||||
drizzle-kit: '>=0.31.4'
|
||||
drizzle-orm: '>=0.41.0'
|
||||
mongodb: ^6.0.0 || ^7.0.0
|
||||
mysql2: ^3.0.0
|
||||
next: ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||
pg: ^8.0.0
|
||||
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
solid-js: ^1.0.0
|
||||
svelte: ^4.0.0 || ^5.0.0
|
||||
vitest: ^2.0.0 || ^3.0.0 || ^4.0.0
|
||||
vue: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
'@lynx-js/react':
|
||||
optional: true
|
||||
'@prisma/client':
|
||||
optional: true
|
||||
'@sveltejs/kit':
|
||||
optional: true
|
||||
'@tanstack/react-start':
|
||||
optional: true
|
||||
'@tanstack/solid-start':
|
||||
optional: true
|
||||
better-sqlite3:
|
||||
optional: true
|
||||
drizzle-kit:
|
||||
optional: true
|
||||
drizzle-orm:
|
||||
optional: true
|
||||
mongodb:
|
||||
optional: true
|
||||
mysql2:
|
||||
optional: true
|
||||
next:
|
||||
optional: true
|
||||
pg:
|
||||
optional: true
|
||||
prisma:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
solid-js:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vitest:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
better-call@1.3.2:
|
||||
resolution: {integrity: sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==}
|
||||
peerDependencies:
|
||||
zod: ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
bowser@2.14.1:
|
||||
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
||||
|
||||
@@ -2629,6 +2790,9 @@ packages:
|
||||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
defu@6.1.4:
|
||||
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3283,9 +3447,6 @@ packages:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@5.10.0:
|
||||
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
||||
|
||||
jose@6.2.1:
|
||||
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
|
||||
|
||||
@@ -3346,6 +3507,10 @@ packages:
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
kysely@0.28.14:
|
||||
resolution: {integrity: sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
leven@3.1.0:
|
||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3510,6 +3675,10 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanostores@1.2.0:
|
||||
resolution: {integrity: sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==}
|
||||
engines: {node: ^20.0.0 || >=22.0.0}
|
||||
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
@@ -3527,9 +3696,6 @@ packages:
|
||||
nwsapi@2.2.23:
|
||||
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
|
||||
|
||||
oauth4webapi@3.8.5:
|
||||
resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3542,9 +3708,6 @@ packages:
|
||||
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
openid-client@6.8.2:
|
||||
resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -3777,6 +3940,9 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
rou3@0.7.12:
|
||||
resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
|
||||
|
||||
rrweb-cssom@0.8.0:
|
||||
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||
|
||||
@@ -3820,6 +3986,9 @@ packages:
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
set-cookie-parser@3.1.0:
|
||||
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4369,8 +4538,8 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
@@ -5524,6 +5693,56 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)':
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
'@standard-schema/spec': 1.1.0
|
||||
better-call: 1.3.2(zod@4.3.6)
|
||||
jose: 6.2.1
|
||||
kysely: 0.28.14
|
||||
nanostores: 1.2.0
|
||||
zod: 4.3.6
|
||||
|
||||
'@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
|
||||
'@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
optionalDependencies:
|
||||
kysely: 0.28.14
|
||||
|
||||
'@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
|
||||
'@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
|
||||
'@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
|
||||
'@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
|
||||
'@better-auth/utils@0.3.1': {}
|
||||
|
||||
'@better-fetch/fetch@1.1.21': {}
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
@@ -5897,10 +6116,10 @@ snapshots:
|
||||
dependencies:
|
||||
hono: 4.12.8
|
||||
|
||||
'@hono/zod-validator@0.4.3(hono@4.12.8)(zod@3.25.76)':
|
||||
'@hono/zod-validator@0.7.6(hono@4.12.8)(zod@4.3.6)':
|
||||
dependencies:
|
||||
hono: 4.12.8
|
||||
zod: 3.25.76
|
||||
zod: 4.3.6
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
@@ -5950,6 +6169,14 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@noble/ciphers@2.1.1': {}
|
||||
|
||||
'@noble/hashes@2.0.1': {}
|
||||
|
||||
'@opentelemetry/api@1.9.1': {}
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.40.0': {}
|
||||
|
||||
'@petamoriken/float16@3.9.3': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
@@ -6903,6 +7130,42 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.8: {}
|
||||
|
||||
better-auth@1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14)
|
||||
'@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.14)(nanostores@1.2.0))
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@noble/ciphers': 2.1.1
|
||||
'@noble/hashes': 2.0.1
|
||||
better-call: 1.3.2(zod@4.3.6)
|
||||
defu: 6.1.4
|
||||
jose: 6.2.1
|
||||
kysely: 0.28.14
|
||||
nanostores: 1.2.0
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)
|
||||
transitivePeerDependencies:
|
||||
- '@cloudflare/workers-types'
|
||||
- '@opentelemetry/api'
|
||||
|
||||
better-call@1.3.2(zod@4.3.6):
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
rou3: 0.7.12
|
||||
set-cookie-parser: 3.1.0
|
||||
optionalDependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
bowser@2.14.1: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
@@ -7092,6 +7355,8 @@ snapshots:
|
||||
has-property-descriptors: 1.0.2
|
||||
object-keys: 1.1.1
|
||||
|
||||
defu@6.1.4: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
@@ -7110,9 +7375,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
drizzle-orm@0.38.4(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4):
|
||||
drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/react@19.2.14)(kysely@0.28.14)(postgres@3.4.8)(react@19.2.4):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/react': 19.2.14
|
||||
kysely: 0.28.14
|
||||
postgres: 3.4.8
|
||||
react: 19.2.4
|
||||
|
||||
@@ -7820,8 +8087,6 @@ snapshots:
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@5.10.0: {}
|
||||
|
||||
jose@6.2.1: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
@@ -7887,6 +8152,8 @@ snapshots:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
kysely@0.28.14: {}
|
||||
|
||||
leven@3.1.0: {}
|
||||
|
||||
levn@0.4.1:
|
||||
@@ -8015,6 +8282,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanostores@1.2.0: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
node-cron@3.0.3:
|
||||
@@ -8027,8 +8296,6 @@ snapshots:
|
||||
|
||||
nwsapi@2.2.23: {}
|
||||
|
||||
oauth4webapi@3.8.5: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
object-keys@1.1.1: {}
|
||||
@@ -8042,11 +8309,6 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
object-keys: 1.1.1
|
||||
|
||||
openid-client@6.8.2:
|
||||
dependencies:
|
||||
jose: 6.2.1
|
||||
oauth4webapi: 3.8.5
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -8299,6 +8561,8 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.59.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
rou3@0.7.12: {}
|
||||
|
||||
rrweb-cssom@0.8.0: {}
|
||||
|
||||
safe-array-concat@1.1.3:
|
||||
@@ -8340,6 +8604,8 @@ snapshots:
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
set-cookie-parser@3.1.0: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -9008,4 +9274,4 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
zod@4.3.6: {}
|
||||
|
||||
Reference in New Issue
Block a user