Initial commit: InflateHQ — Square to CalDAV approval app
Ingests Square invoices/payments, extracts event scheduling from order notes, and pushes approved entries to a CalDAV calendar after human review. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
27589ab99f
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
|
docs
|
||||||
50
.env.example
Normal file
50
.env.example
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Copy this file to .env and fill in real values before running
|
||||||
|
# `docker compose up`. Never commit the real .env file.
|
||||||
|
|
||||||
|
# --- App ---
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
# Generate with: openssl rand -base64 48
|
||||||
|
JWT_SECRET=changeme-generate-a-long-random-string
|
||||||
|
|
||||||
|
# IANA timezone the business operates in (e.g. America/New_York, America/Chicago).
|
||||||
|
# Used to interpret dates/times extracted from delivery/strike order notes, and
|
||||||
|
# as a fallback for invoices Square doesn't tag with a timezone. Defaults to
|
||||||
|
# UTC, which is almost certainly wrong for you — set this.
|
||||||
|
BUSINESS_TIMEZONE=America/New_York
|
||||||
|
|
||||||
|
# --- Bootstrap admin (only used the very first time the app starts, when
|
||||||
|
# the users table is empty) ---
|
||||||
|
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD=changeme
|
||||||
|
BOOTSTRAP_ADMIN_NAME=Admin
|
||||||
|
|
||||||
|
# --- Database (used by both the app and the postgres container) ---
|
||||||
|
POSTGRES_USER=ordertopdf
|
||||||
|
POSTGRES_PASSWORD=changeme
|
||||||
|
POSTGRES_DB=ordertopdf
|
||||||
|
# When running outside docker-compose, set the full URL yourself, e.g.:
|
||||||
|
# DATABASE_URL=postgresql://ordertopdf:changeme@localhost:5432/ordertopdf
|
||||||
|
|
||||||
|
# --- Square (developer.squareup.com > your app > Credentials) ---
|
||||||
|
SQUARE_ENVIRONMENT=sandbox
|
||||||
|
SQUARE_APP_ID=sq0idp-xxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
SQUARE_ACCESS_TOKEN=EAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
# Optional: comma-separated location IDs. Leave blank to sync all locations.
|
||||||
|
SQUARE_LOCATION_ID=
|
||||||
|
SYNC_INTERVAL_CRON=*/15 * * * *
|
||||||
|
SYNC_MAX_PAGES=10
|
||||||
|
SYNC_OVERLAP_MINUTES=60
|
||||||
|
# Square's Invoices API has no date filter, so we page newest-first and stop
|
||||||
|
# once we're this many days back in invoice history.
|
||||||
|
SYNC_INVOICE_LOOKBACK_DAYS=180
|
||||||
|
PAYMENT_DEFAULT_DURATION_MINUTES=60
|
||||||
|
|
||||||
|
# --- CalDAV ---
|
||||||
|
# The CalDAV server root (used for auth/discovery).
|
||||||
|
CALDAV_SERVER_URL=https://cloud.example.com/remote.php/dav
|
||||||
|
# The specific calendar collection events get written to. MUST end with a
|
||||||
|
# trailing slash. See docs/caldav-setup.md for how to find this per provider.
|
||||||
|
CALDAV_CALENDAR_URL=https://cloud.example.com/remote.php/dav/calendars/user/orders/
|
||||||
|
CALDAV_USERNAME=caldavuser
|
||||||
|
CALDAV_PASSWORD=app-specific-password
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
server/prisma/dev.db
|
||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# --- deps & build ---
|
||||||
|
# Using the full (non-slim) image because it already bundles OpenSSL, which
|
||||||
|
# Prisma's query engine is dynamically linked against.
|
||||||
|
FROM node:22-bookworm AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY server/package.json server/package.json
|
||||||
|
COPY web/package.json web/package.json
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run prisma:generate
|
||||||
|
RUN npm run build:web
|
||||||
|
RUN npm run build:server
|
||||||
|
|
||||||
|
# --- runtime ---
|
||||||
|
FROM node:22-bookworm AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/package.json ./package.json
|
||||||
|
COPY --from=build /app/server/package.json ./server/package.json
|
||||||
|
COPY --from=build /app/server/dist ./server/dist
|
||||||
|
COPY --from=build /app/server/prisma ./server/prisma
|
||||||
|
COPY --from=build /app/web/dist ./web/dist
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD node server/dist/healthcheck.js || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server/dist/entrypoint.js"]
|
||||||
81
README.md
Normal file
81
README.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# Order to Calendar
|
||||||
|
|
||||||
|
Pulls invoices and payments from Square, lets a human review and edit each one, and pushes approved
|
||||||
|
entries to a CalDAV calendar. Mobile-friendly (works as a PWA — "Add to Home Screen"), multi-user
|
||||||
|
login, ships as a single Docker image plus Postgres.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. A background job (or the "Sync now" button) polls the Square Invoices and Payments APIs and
|
||||||
|
turns new/updated invoices and payments into **candidate** calendar entries with status `PENDING`.
|
||||||
|
2. Signed-in users review the pending queue on any device, edit fields if needed (title, date/time,
|
||||||
|
description, location, customer info, amount), and either **Approve** or **Reject** each one.
|
||||||
|
3. Approving creates (or updates) an event on your CalDAV calendar. Nothing reaches the calendar
|
||||||
|
without a human approving it first.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A Square account with API credentials ([developer.squareup.com](https://developer.squareup.com) → your app → Credentials).
|
||||||
|
- A CalDAV calendar (Nextcloud, Fastmail, iCloud, or self-hosted) — see [docs/caldav-setup.md](docs/caldav-setup.md).
|
||||||
|
- Docker + Docker Compose on the server you're deploying to.
|
||||||
|
|
||||||
|
## Deploying
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env: JWT_SECRET, BOOTSTRAP_ADMIN_*, POSTGRES_*, SQUARE_*, CALDAV_*
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The app listens on port 3000 (`PORT` in `.env`) — put your existing reverse proxy (nginx, Caddy,
|
||||||
|
Traefik, etc.) in front of it for TLS. On first boot it runs database migrations and creates the
|
||||||
|
initial admin account from `BOOTSTRAP_ADMIN_EMAIL` / `BOOTSTRAP_ADMIN_PASSWORD`. Log in as that
|
||||||
|
admin, then create accounts for everyone else from **Admin → New user** — there is no open
|
||||||
|
self-registration.
|
||||||
|
|
||||||
|
To go live, generate a real Square access token for `SQUARE_ENVIRONMENT=production` once you've
|
||||||
|
verified the sandbox flow end to end.
|
||||||
|
|
||||||
|
### Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations run automatically on container start; existing data in the `db-data` volume is preserved.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run prisma:generate
|
||||||
|
# start a local Postgres however you like, then create the schema:
|
||||||
|
DATABASE_URL=postgresql://... npx prisma migrate dev --schema server/prisma/schema.prisma
|
||||||
|
|
||||||
|
npm run dev:server # backend on :3000
|
||||||
|
npm run dev:web # frontend on :5173, proxies /api to :3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration reference
|
||||||
|
|
||||||
|
See [.env.example](.env.example) for every environment variable, with comments.
|
||||||
|
|
||||||
|
Key ones worth knowing:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `SQUARE_LOCATION_ID` | Leave blank to sync every location on the account, or set a comma-separated list to scope it. |
|
||||||
|
| `SYNC_INTERVAL_CRON` | How often the background sync runs (default: every 15 minutes). |
|
||||||
|
| `PAYMENT_DEFAULT_DURATION_MINUTES` | Payments have no inherent duration in Square — this sets the calendar event length. |
|
||||||
|
| `CALDAV_CALENDAR_URL` | Must be the specific calendar collection URL, not just the server root, and must end with `/`. |
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
- **Idempotent sync**: re-running sync never duplicates a candidate (unique on Square ID + source
|
||||||
|
type + environment). Once a candidate is approved or rejected it's frozen — a later Square-side
|
||||||
|
edit won't silently overwrite a human decision.
|
||||||
|
- **Idempotent CalDAV writes**: each approved candidate keeps its CalDAV event UID/URL/etag, so
|
||||||
|
editing an already-approved item and re-approving updates the existing event instead of creating
|
||||||
|
a duplicate.
|
||||||
|
- **Auth**: httpOnly JWT cookie sessions (no separate session store), passwords hashed with argon2id.
|
||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
50
docs/caldav-setup.md
Normal file
50
docs/caldav-setup.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Finding your CalDAV URLs
|
||||||
|
|
||||||
|
The app needs two URLs:
|
||||||
|
|
||||||
|
- `CALDAV_SERVER_URL` — the DAV root used for authentication.
|
||||||
|
- `CALDAV_CALENDAR_URL` — the exact calendar collection events get written to. **Must end with a trailing slash.**
|
||||||
|
|
||||||
|
Create a dedicated calendar first (e.g. "Orders") so approved entries don't mix in with a personal calendar, then find its URL below.
|
||||||
|
|
||||||
|
## Nextcloud
|
||||||
|
|
||||||
|
1. Settings → your calendar app → click the three-dot menu next to the calendar → "Link" or "Copy private link".
|
||||||
|
2. `CALDAV_SERVER_URL`: `https://<your-nextcloud-domain>/remote.php/dav`
|
||||||
|
3. `CALDAV_CALENDAR_URL`: `https://<your-nextcloud-domain>/remote.php/dav/calendars/<username>/<calendar-name>/`
|
||||||
|
4. Use an [app password](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#managing-devices) for `CALDAV_PASSWORD`, not your account password.
|
||||||
|
|
||||||
|
## Fastmail
|
||||||
|
|
||||||
|
1. Settings → Calendars → click the calendar → copy the "CalDAV URL".
|
||||||
|
2. `CALDAV_SERVER_URL`: `https://caldav.fastmail.com/dav/calendars`
|
||||||
|
3. `CALDAV_CALENDAR_URL`: the full URL copied above (ends in `/`).
|
||||||
|
4. Generate an [app password](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords) scoped to CalDAV.
|
||||||
|
|
||||||
|
## iCloud
|
||||||
|
|
||||||
|
1. `CALDAV_SERVER_URL`: `https://caldav.icloud.com`
|
||||||
|
2. Generate an [app-specific password](https://support.apple.com/en-us/102654) for `CALDAV_PASSWORD` (2FA required).
|
||||||
|
3. `CALDAV_CALENDAR_URL` requires discovering your principal path — the simplest way is to temporarily log the calendars from a Node REPL:
|
||||||
|
```js
|
||||||
|
const { DAVClient } = require("tsdav");
|
||||||
|
const client = new DAVClient({
|
||||||
|
serverUrl: "https://caldav.icloud.com",
|
||||||
|
credentials: { username: "you@icloud.com", password: "app-specific-password" },
|
||||||
|
authMethod: "Basic",
|
||||||
|
defaultAccountType: "caldav",
|
||||||
|
});
|
||||||
|
await client.login();
|
||||||
|
console.log(await client.fetchCalendars());
|
||||||
|
```
|
||||||
|
Pick the `url` of the calendar you want from the printed list.
|
||||||
|
|
||||||
|
## Self-hosted Radicale / Baïkal
|
||||||
|
|
||||||
|
- `CALDAV_SERVER_URL` is your server's base URL (e.g. `https://dav.example.com`).
|
||||||
|
- `CALDAV_CALENDAR_URL` is the collection URL for the specific calendar, typically `https://dav.example.com/<username>/<calendar-id>/`.
|
||||||
|
- Credentials are whatever username/password (or app-specific token) your server issues.
|
||||||
|
|
||||||
|
## Verifying
|
||||||
|
|
||||||
|
After setting the env vars, approve one candidate in the app and confirm the event shows up in your calendar client. If it fails, check the app logs (`docker compose logs -f app`) — CalDAV write failures are returned with the server's status code and response body.
|
||||||
9666
package-lock.json
generated
Normal file
9666
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "inflatehq",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"web"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev:server": "npm run dev --workspace server",
|
||||||
|
"dev:web": "npm run dev --workspace web",
|
||||||
|
"build:server": "npm run build --workspace server",
|
||||||
|
"build:web": "npm run build --workspace web",
|
||||||
|
"prisma:generate": "npm run prisma:generate --workspace server",
|
||||||
|
"prisma:migrate": "npm run prisma:migrate --workspace server"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
server/package.json
Normal file
40
server/package.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/entrypoint.js",
|
||||||
|
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
|
||||||
|
"prisma:migrate": "prisma migrate dev --schema prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
|
"argon2": "^0.45.1",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.6.2",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
|
"ical-generator": "^11.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
|
"node-cron": "^4.6.0",
|
||||||
|
"pino": "^10.3.1",
|
||||||
|
"pino-http": "^11.0.0",
|
||||||
|
"square": "^45.1.0",
|
||||||
|
"tsdav": "^2.3.1",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
server/prisma/migrations/20260820162218_init/migration.sql
Normal file
84
server/prisma/migrations/20260820162218_init/migration.sql
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SourceType" AS ENUM ('SQUARE_INVOICE', 'SQUARE_PAYMENT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CandidateStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"role" "Role" NOT NULL DEFAULT 'USER',
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdById" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CalendarCandidate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sourceType" "SourceType" NOT NULL,
|
||||||
|
"squareId" TEXT NOT NULL,
|
||||||
|
"squareEnvironment" TEXT NOT NULL,
|
||||||
|
"status" "CandidateStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"startAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"endAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"allDay" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"location" TEXT,
|
||||||
|
"customerName" TEXT,
|
||||||
|
"customerEmail" TEXT,
|
||||||
|
"amountCents" INTEGER,
|
||||||
|
"currency" TEXT DEFAULT 'USD',
|
||||||
|
"rawPayload" JSONB NOT NULL,
|
||||||
|
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"approvedById" TEXT,
|
||||||
|
"approvedAt" TIMESTAMP(3),
|
||||||
|
"rejectedAt" TIMESTAMP(3),
|
||||||
|
"caldavEventUid" TEXT,
|
||||||
|
"caldavEventUrl" TEXT,
|
||||||
|
"caldavEtag" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "CalendarCandidate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SyncRun" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"finishedAt" TIMESTAMP(3),
|
||||||
|
"trigger" TEXT NOT NULL,
|
||||||
|
"invoicesFetched" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"paymentsFetched" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"candidatesCreated" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"error" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "SyncRun_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CalendarCandidate_caldavEventUid_key" ON "CalendarCandidate"("caldavEventUid");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CalendarCandidate_status_idx" ON "CalendarCandidate"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CalendarCandidate_sourceType_squareId_squareEnvironment_key" ON "CalendarCandidate"("sourceType", "squareId", "squareEnvironment");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CalendarCandidate" ADD CONSTRAINT "CalendarCandidate_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CalendarCandidate" ADD COLUMN "squareStatus" TEXT;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CalendarCandidate" ADD COLUMN "squareCreatedAt" TIMESTAMP(3);
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CalendarCandidate" ADD COLUMN "hasStrike" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "strikeStartAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "strikeEndAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "strikeCaldavEventUid" TEXT,
|
||||||
|
ADD COLUMN "strikeCaldavEventUrl" TEXT,
|
||||||
|
ADD COLUMN "strikeCaldavEtag" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CalendarCandidate_strikeCaldavEventUid_key" ON "CalendarCandidate"("strikeCaldavEventUid");
|
||||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
101
server/prisma/schema.prisma
Normal file
101
server/prisma/schema.prisma
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SourceType {
|
||||||
|
SQUARE_INVOICE
|
||||||
|
SQUARE_PAYMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CandidateStatus {
|
||||||
|
PENDING
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
name String
|
||||||
|
role Role @default(USER)
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
createdById String?
|
||||||
|
createdBy User? @relation("UserCreatedBy", fields: [createdById], references: [id])
|
||||||
|
createdUsers User[] @relation("UserCreatedBy")
|
||||||
|
|
||||||
|
approvals CalendarCandidate[] @relation("ApprovedBy")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CalendarCandidate {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
sourceType SourceType
|
||||||
|
squareId String
|
||||||
|
squareEnvironment String
|
||||||
|
status CandidateStatus @default(PENDING)
|
||||||
|
// Square's own status (invoice: DRAFT/UNPAID/PAID/etc, payment: COMPLETED/etc) —
|
||||||
|
// independent of our review `status` above, kept for filtering.
|
||||||
|
squareStatus String?
|
||||||
|
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
// When Square created the invoice/payment — distinct from `startAt` (the
|
||||||
|
// event's own date/time). Square's own invoice list sorts by this.
|
||||||
|
squareCreatedAt DateTime?
|
||||||
|
startAt DateTime
|
||||||
|
endAt DateTime
|
||||||
|
allDay Boolean @default(false)
|
||||||
|
location String?
|
||||||
|
customerName String?
|
||||||
|
customerEmail String?
|
||||||
|
amountCents Int?
|
||||||
|
currency String? @default("USD")
|
||||||
|
|
||||||
|
rawPayload Json
|
||||||
|
fetchedAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
approvedById String?
|
||||||
|
approvedBy User? @relation("ApprovedBy", fields: [approvedById], references: [id])
|
||||||
|
approvedAt DateTime?
|
||||||
|
rejectedAt DateTime?
|
||||||
|
|
||||||
|
caldavEventUid String? @unique
|
||||||
|
caldavEventUrl String?
|
||||||
|
caldavEtag String?
|
||||||
|
|
||||||
|
// Strike (teardown/pickup) as its own, separate calendar entry — often a
|
||||||
|
// different day than the main event, so it gets its own CalDAV event too.
|
||||||
|
hasStrike Boolean @default(false)
|
||||||
|
strikeStartAt DateTime?
|
||||||
|
strikeEndAt DateTime?
|
||||||
|
strikeCaldavEventUid String? @unique
|
||||||
|
strikeCaldavEventUrl String?
|
||||||
|
strikeCaldavEtag String?
|
||||||
|
|
||||||
|
@@unique([sourceType, squareId, squareEnvironment], name: "square_source_unique")
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SyncRun {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
finishedAt DateTime?
|
||||||
|
trigger String
|
||||||
|
invoicesFetched Int @default(0)
|
||||||
|
paymentsFetched Int @default(0)
|
||||||
|
candidatesCreated Int @default(0)
|
||||||
|
error String?
|
||||||
|
}
|
||||||
97
server/src/auth/middleware.ts
Normal file
97
server/src/auth/middleware.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { env } from "../env.js";
|
||||||
|
import { prisma } from "../db.js";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "session";
|
||||||
|
const TOKEN_TTL_SECONDS = 12 * 60 * 60; // 12h
|
||||||
|
const REFRESH_THRESHOLD_SECONDS = 2 * 60 * 60; // reissue if <2h remaining
|
||||||
|
|
||||||
|
interface SessionPayload {
|
||||||
|
sub: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthedRequest extends Request {
|
||||||
|
user?: { id: string; role: Role };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signToken(payload: SessionPayload): string {
|
||||||
|
return jwt.sign(payload, env.JWT_SECRET, { expiresIn: TOKEN_TTL_SECONDS });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionCookie(res: Response, payload: SessionPayload): void {
|
||||||
|
const token = signToken(payload);
|
||||||
|
res.cookie(COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: TOKEN_TTL_SECONDS * 1000,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionCookie(res: Response): void {
|
||||||
|
res.clearCookie(COOKIE_NAME, { path: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookies(header: string | undefined): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (!header) return out;
|
||||||
|
for (const part of header.split(";")) {
|
||||||
|
const idx = part.indexOf("=");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const key = part.slice(0, idx).trim();
|
||||||
|
const val = part.slice(idx + 1).trim();
|
||||||
|
if (key) out[key] = decodeURIComponent(val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||||
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
|
const token = cookies[COOKIE_NAME];
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ error: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded: (SessionPayload & jwt.JwtPayload) | undefined;
|
||||||
|
try {
|
||||||
|
decoded = jwt.verify(token, env.JWT_SECRET) as SessionPayload & jwt.JwtPayload;
|
||||||
|
} catch {
|
||||||
|
clearSessionCookie(res);
|
||||||
|
res.status(401).json({ error: "Session expired" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
|
||||||
|
if (!user || !user.active) {
|
||||||
|
clearSessionCookie(res);
|
||||||
|
res.status(401).json({ error: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = { id: user.id, role: user.role };
|
||||||
|
|
||||||
|
// silent refresh if the token is getting close to expiry
|
||||||
|
const exp = decoded.exp ?? 0;
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
if (exp - nowSeconds < REFRESH_THRESHOLD_SECONDS) {
|
||||||
|
setSessionCookie(res, { sub: user.id, role: user.role });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAdmin(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||||
|
if (req.user?.role !== "ADMIN") {
|
||||||
|
res.status(403).json({ error: "Admin access required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { COOKIE_NAME };
|
||||||
|
export type { SessionPayload };
|
||||||
9
server/src/auth/password.ts
Normal file
9
server/src/auth/password.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import argon2 from "argon2";
|
||||||
|
|
||||||
|
export function hashPassword(plain: string): Promise<string> {
|
||||||
|
return argon2.hash(plain, { type: argon2.argon2id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(hash: string, plain: string): Promise<boolean> {
|
||||||
|
return argon2.verify(hash, plain);
|
||||||
|
}
|
||||||
90
server/src/auth/routes.ts
Normal file
90
server/src/auth/routes.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../db.js";
|
||||||
|
import { hashPassword, verifyPassword } from "./password.js";
|
||||||
|
import { clearSessionCookie, requireAuth, setSessionCookie, type AuthedRequest } from "./middleware.js";
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post("/login", async (req, res) => {
|
||||||
|
const parsed = loginSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid email or password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password } = parsed.data;
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||||
|
if (!user || !user.active) {
|
||||||
|
res.status(401).json({ error: "Invalid credentials" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(user.passwordHash, password);
|
||||||
|
if (!valid) {
|
||||||
|
res.status(401).json({ error: "Invalid credentials" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionCookie(res, { sub: user.id, role: user.role });
|
||||||
|
res.json({ id: user.id, email: user.email, name: user.name, role: user.role });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post("/logout", (_req, res) => {
|
||||||
|
clearSessionCookie(res);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get("/me", requireAuth, async (req: AuthedRequest, res) => {
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: req.user!.id } });
|
||||||
|
res.json({ id: user.id, email: user.email, name: user.name, role: user.role });
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMeSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
newPassword: z.string().min(8).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.patch("/me", requireAuth, async (req: AuthedRequest, res) => {
|
||||||
|
const parsed = updateMeSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Invalid input" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { currentPassword, name, email, newPassword } = parsed.data;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: req.user!.id } });
|
||||||
|
const valid = await verifyPassword(user.passwordHash, currentPassword);
|
||||||
|
if (!valid) {
|
||||||
|
res.status(401).json({ error: "Current password is incorrect" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = email?.toLowerCase();
|
||||||
|
if (normalizedEmail && normalizedEmail !== user.email) {
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||||
|
if (existing) {
|
||||||
|
res.status(409).json({ error: "A user with that email already exists" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email: normalizedEmail,
|
||||||
|
passwordHash: newPassword ? await hashPassword(newPassword) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setSessionCookie(res, { sub: updated.id, role: updated.role });
|
||||||
|
res.json({ id: updated.id, email: updated.email, name: updated.name, role: updated.role });
|
||||||
|
});
|
||||||
29
server/src/bootstrap.ts
Normal file
29
server/src/bootstrap.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { env } from "./env.js";
|
||||||
|
import { prisma } from "./db.js";
|
||||||
|
import { hashPassword } from "./auth/password.js";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const userCount = await prisma.user.count();
|
||||||
|
if (userCount > 0) {
|
||||||
|
console.log("Bootstrap: users already exist, skipping admin creation.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(env.BOOTSTRAP_ADMIN_PASSWORD);
|
||||||
|
const admin = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: env.BOOTSTRAP_ADMIN_EMAIL.toLowerCase(),
|
||||||
|
name: env.BOOTSTRAP_ADMIN_NAME,
|
||||||
|
passwordHash,
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Bootstrap: created initial admin user ${admin.email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Bootstrap failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
29
server/src/caldav/client.ts
Normal file
29
server/src/caldav/client.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { DAVClient, type DAVCalendar } from "tsdav";
|
||||||
|
import { env } from "../env.js";
|
||||||
|
|
||||||
|
let clientPromise: Promise<DAVClient> | undefined;
|
||||||
|
|
||||||
|
export function getDavClient(): Promise<DAVClient> {
|
||||||
|
if (!clientPromise) {
|
||||||
|
clientPromise = (async () => {
|
||||||
|
const client = new DAVClient({
|
||||||
|
serverUrl: env.CALDAV_SERVER_URL,
|
||||||
|
credentials: { username: env.CALDAV_USERNAME, password: env.CALDAV_PASSWORD },
|
||||||
|
authMethod: "Basic",
|
||||||
|
defaultAccountType: "caldav",
|
||||||
|
});
|
||||||
|
await client.login();
|
||||||
|
return client;
|
||||||
|
})().catch((err) => {
|
||||||
|
clientPromise = undefined; // allow retry on next call
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return clientPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCalendarObject/updateCalendarObject only need `.url`, so we don't need
|
||||||
|
// to discover the calendar via PROPFIND — the collection URL is configured directly.
|
||||||
|
export function getTargetCalendar(): DAVCalendar {
|
||||||
|
return { url: env.CALDAV_CALENDAR_URL };
|
||||||
|
}
|
||||||
85
server/src/caldav/events.ts
Normal file
85
server/src/caldav/events.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import ical from "ical-generator";
|
||||||
|
import { getDavClient, getTargetCalendar } from "./client.js";
|
||||||
|
|
||||||
|
export interface CaldavEventInput {
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
startAt: Date;
|
||||||
|
endAt: Date;
|
||||||
|
allDay: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaldavWriteResult {
|
||||||
|
uid: string;
|
||||||
|
url: string;
|
||||||
|
etag: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIcs(uid: string, input: CaldavEventInput): string {
|
||||||
|
const calendar = ical({ name: "Order to Calendar" });
|
||||||
|
calendar.createEvent({
|
||||||
|
id: uid,
|
||||||
|
start: input.startAt,
|
||||||
|
end: input.endAt,
|
||||||
|
allDay: input.allDay,
|
||||||
|
summary: input.title,
|
||||||
|
description: input.description ?? undefined,
|
||||||
|
location: input.location ?? undefined,
|
||||||
|
});
|
||||||
|
return calendar.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
class CaldavWriteError extends Error {
|
||||||
|
constructor(message: string, readonly status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CaldavWriteError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CaldavWriteError };
|
||||||
|
|
||||||
|
export async function createCaldavEvent(input: CaldavEventInput): Promise<CaldavWriteResult> {
|
||||||
|
const client = await getDavClient();
|
||||||
|
const calendar = getTargetCalendar();
|
||||||
|
const uid = `${crypto.randomUUID()}@ordertopdf`;
|
||||||
|
const filename = `${uid}.ics`;
|
||||||
|
const iCalString = buildIcs(uid, input);
|
||||||
|
|
||||||
|
const res = await client.createCalendarObject({ calendar, iCalString, filename });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new CaldavWriteError(`CalDAV create failed: ${res.status} ${await res.text()}`, res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(filename, calendar.url).href;
|
||||||
|
const etag = res.headers.get("etag");
|
||||||
|
return { uid, url, etag };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCaldavEvent(
|
||||||
|
uid: string,
|
||||||
|
url: string,
|
||||||
|
etag: string | null | undefined,
|
||||||
|
input: CaldavEventInput,
|
||||||
|
): Promise<CaldavWriteResult> {
|
||||||
|
const client = await getDavClient();
|
||||||
|
const iCalString = buildIcs(uid, input);
|
||||||
|
|
||||||
|
const res = await client.updateCalendarObject({
|
||||||
|
calendarObject: { url, data: iCalString, etag: etag ?? undefined },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new CaldavWriteError(`CalDAV update failed: ${res.status} ${await res.text()}`, res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEtag = res.headers.get("etag");
|
||||||
|
return { uid, url, etag: newEtag };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCaldavEvent(url: string, etag: string | null | undefined): Promise<void> {
|
||||||
|
const client = await getDavClient();
|
||||||
|
const res = await client.deleteCalendarObject({ calendarObject: { url, etag: etag ?? undefined } });
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
throw new CaldavWriteError(`CalDAV delete failed: ${res.status} ${await res.text()}`, res.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
380
server/src/candidates/routes.ts
Normal file
380
server/src/candidates/routes.ts
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../db.js";
|
||||||
|
import { requireAuth, type AuthedRequest } from "../auth/middleware.js";
|
||||||
|
import { createCaldavEvent, updateCaldavEvent, deleteCaldavEvent, CaldavWriteError } from "../caldav/events.js";
|
||||||
|
import type { CandidateStatus, SourceType } from "@prisma/client";
|
||||||
|
|
||||||
|
export const candidatesRouter = Router();
|
||||||
|
candidatesRouter.use(requireAuth);
|
||||||
|
|
||||||
|
const statusSchema = z.enum(["PENDING", "APPROVED", "REJECTED"]);
|
||||||
|
const sourceTypeSchema = z.enum(["SQUARE_INVOICE", "SQUARE_PAYMENT"]);
|
||||||
|
const sortBySchema = z.enum(["startAt", "amountCents", "updatedAt", "customerName", "squareCreatedAt"]);
|
||||||
|
const sortDirSchema = z.enum(["asc", "desc"]);
|
||||||
|
|
||||||
|
const DATE_SEARCH = /^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free-text search across customer/title/location/description/Square ID,
|
||||||
|
* plus event-date matching: typing "3/21" (no year) matches that month/day
|
||||||
|
* in any of the last/next few years — this business's events recur yearly,
|
||||||
|
* so an exact-year-only match would miss what people actually mean.
|
||||||
|
*/
|
||||||
|
function buildSearchFilter(query: string | undefined) {
|
||||||
|
const q = query?.trim();
|
||||||
|
if (!q) return undefined;
|
||||||
|
|
||||||
|
const textConditions = ["customerName", "customerEmail", "title", "location", "description", "squareId"].map(
|
||||||
|
(field) => ({ [field]: { contains: q, mode: "insensitive" as const } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateMatch = q.match(DATE_SEARCH);
|
||||||
|
const dateConditions: { startAt: { gte: Date; lt: Date } }[] = [];
|
||||||
|
if (dateMatch) {
|
||||||
|
const month = Number(dateMatch[1]);
|
||||||
|
const day = Number(dateMatch[2]);
|
||||||
|
const yearStr = dateMatch[3];
|
||||||
|
const years = yearStr
|
||||||
|
? [yearStr.length === 2 ? 2000 + Number(yearStr) : Number(yearStr)]
|
||||||
|
: Array.from({ length: 4 }, (_, i) => new Date().getFullYear() - 1 + i);
|
||||||
|
for (const year of years) {
|
||||||
|
const start = new Date(year, month - 1, day);
|
||||||
|
const end = new Date(year, month - 1, day + 1);
|
||||||
|
if (!isNaN(start.getTime())) dateConditions.push({ startAt: { gte: start, lt: end } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { OR: [...textConditions, ...dateConditions] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StrikeCaldavState {
|
||||||
|
hasStrike: boolean;
|
||||||
|
strikeCaldavEventUid: string | null;
|
||||||
|
strikeCaldavEventUrl: string | null;
|
||||||
|
strikeCaldavEtag: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates, updates, or removes the strike's own CalDAV event to match the
|
||||||
|
* candidate's current hasStrike/strikeStartAt/strikeEndAt — mirrors the main
|
||||||
|
* event's create/update logic in approve/PATCH below, just for the second entry.
|
||||||
|
*/
|
||||||
|
async function syncStrikeCaldav(
|
||||||
|
existing: StrikeCaldavState,
|
||||||
|
merged: { title: string; location: string | null; hasStrike: boolean; strikeStartAt: Date | null; strikeEndAt: Date | null },
|
||||||
|
): Promise<Pick<StrikeCaldavState, "strikeCaldavEventUid" | "strikeCaldavEventUrl" | "strikeCaldavEtag">> {
|
||||||
|
if (!merged.hasStrike || !merged.strikeStartAt || !merged.strikeEndAt) {
|
||||||
|
if (existing.strikeCaldavEventUrl) {
|
||||||
|
await deleteCaldavEvent(existing.strikeCaldavEventUrl, existing.strikeCaldavEtag);
|
||||||
|
}
|
||||||
|
return { strikeCaldavEventUid: null, strikeCaldavEventUrl: null, strikeCaldavEtag: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventInput = {
|
||||||
|
title: `${merged.title} - Strike`,
|
||||||
|
description: null,
|
||||||
|
location: merged.location,
|
||||||
|
startAt: merged.strikeStartAt,
|
||||||
|
endAt: merged.strikeEndAt,
|
||||||
|
allDay: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result =
|
||||||
|
existing.strikeCaldavEventUid && existing.strikeCaldavEventUrl
|
||||||
|
? await updateCaldavEvent(existing.strikeCaldavEventUid, existing.strikeCaldavEventUrl, existing.strikeCaldavEtag, eventInput)
|
||||||
|
: await createCaldavEvent(eventInput);
|
||||||
|
|
||||||
|
return { strikeCaldavEventUid: result.uid, strikeCaldavEventUrl: result.url, strikeCaldavEtag: result.etag };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpaginated, date-range view used by the Calendar page — every matching
|
||||||
|
// item in a month, always sorted chronologically (a month never has enough
|
||||||
|
// entries for a small business to need paging). Kept as its own route so it
|
||||||
|
// can't collide with the paginated list's own (separate) date-range filter.
|
||||||
|
candidatesRouter.get("/calendar", async (req, res) => {
|
||||||
|
const sourceTypeParam = sourceTypeSchema.safeParse(req.query.sourceType);
|
||||||
|
const sourceType: SourceType | undefined = sourceTypeParam.success ? sourceTypeParam.data : undefined;
|
||||||
|
|
||||||
|
const fromParam = typeof req.query.from === "string" ? new Date(req.query.from) : undefined;
|
||||||
|
const toParam = typeof req.query.to === "string" ? new Date(req.query.to) : undefined;
|
||||||
|
if (!fromParam || isNaN(fromParam.getTime()) || !toParam || isNaN(toParam.getTime())) {
|
||||||
|
res.status(400).json({ error: "from and to are required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusesParam = typeof req.query.statuses === "string" ? req.query.statuses.split(",") : [];
|
||||||
|
const statuses = statusesParam
|
||||||
|
.map((s) => statusSchema.safeParse(s))
|
||||||
|
.filter((r): r is { success: true; data: CandidateStatus } => r.success)
|
||||||
|
.map((r) => r.data);
|
||||||
|
|
||||||
|
const items = await prisma.calendarCandidate.findMany({
|
||||||
|
where: {
|
||||||
|
status: statuses.length > 0 ? { in: statuses } : { in: ["PENDING", "APPROVED"] },
|
||||||
|
startAt: { gte: fromParam, lt: toParam },
|
||||||
|
sourceType,
|
||||||
|
},
|
||||||
|
orderBy: { startAt: "asc" },
|
||||||
|
take: 500,
|
||||||
|
});
|
||||||
|
res.json({ items, total: items.length, page: 1, pageSize: items.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
candidatesRouter.get("/", async (req, res) => {
|
||||||
|
const statusParam = statusSchema.safeParse(req.query.status);
|
||||||
|
const status: CandidateStatus | undefined = statusParam.success ? statusParam.data : undefined;
|
||||||
|
const sourceTypeParam = sourceTypeSchema.safeParse(req.query.sourceType);
|
||||||
|
const sourceType: SourceType | undefined = sourceTypeParam.success ? sourceTypeParam.data : undefined;
|
||||||
|
|
||||||
|
const squareStatusParam = typeof req.query.squareStatus === "string" ? req.query.squareStatus.split(",") : [];
|
||||||
|
|
||||||
|
const dateFromParam = typeof req.query.dateFrom === "string" ? new Date(req.query.dateFrom) : undefined;
|
||||||
|
const dateToParam = typeof req.query.dateTo === "string" ? new Date(req.query.dateTo) : undefined;
|
||||||
|
const startAtFilter =
|
||||||
|
dateFromParam || dateToParam
|
||||||
|
? {
|
||||||
|
...(dateFromParam && !isNaN(dateFromParam.getTime()) ? { gte: dateFromParam } : {}),
|
||||||
|
...(dateToParam && !isNaN(dateToParam.getTime()) ? { lt: dateToParam } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const page = Math.max(1, Number(req.query.page) || 1);
|
||||||
|
const pageSize = 25;
|
||||||
|
|
||||||
|
const sortByParam = sortBySchema.safeParse(req.query.sortBy);
|
||||||
|
const sortDirParam = sortDirSchema.safeParse(req.query.sortDir);
|
||||||
|
const sortDir = sortDirParam.success ? sortDirParam.data : "asc";
|
||||||
|
const orderBy = sortByParam.success
|
||||||
|
? sortByParam.data === "customerName"
|
||||||
|
? [{ customerName: sortDir }, { title: sortDir }] // customer-less rows (payments) fall back to title
|
||||||
|
: sortByParam.data === "squareCreatedAt"
|
||||||
|
? { squareCreatedAt: { sort: sortDir, nulls: "last" as const } } // rows from before this field existed
|
||||||
|
: { [sortByParam.data]: sortDir }
|
||||||
|
: status === "PENDING"
|
||||||
|
? { squareCreatedAt: { sort: "desc" as const, nulls: "last" as const } } // matches Square's own invoice list default
|
||||||
|
: { updatedAt: "desc" as const };
|
||||||
|
|
||||||
|
const searchParam = typeof req.query.search === "string" ? req.query.search : undefined;
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
status,
|
||||||
|
sourceType,
|
||||||
|
squareStatus: squareStatusParam.length > 0 ? { in: squareStatusParam } : undefined,
|
||||||
|
startAt: startAtFilter,
|
||||||
|
...buildSearchFilter(searchParam),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
prisma.calendarCandidate.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
prisma.calendarCandidate.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ items, total, page, pageSize });
|
||||||
|
});
|
||||||
|
|
||||||
|
candidatesRouter.get("/:id", async (req, res) => {
|
||||||
|
const item = await prisma.calendarCandidate.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!item) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
startAt: z.coerce.date().optional(),
|
||||||
|
endAt: z.coerce.date().optional(),
|
||||||
|
allDay: z.boolean().optional(),
|
||||||
|
location: z.string().nullable().optional(),
|
||||||
|
customerName: z.string().nullable().optional(),
|
||||||
|
customerEmail: z.string().nullable().optional(),
|
||||||
|
amountCents: z.number().int().nullable().optional(),
|
||||||
|
currency: z.string().nullable().optional(),
|
||||||
|
hasStrike: z.boolean().optional(),
|
||||||
|
strikeStartAt: z.coerce.date().nullable().optional(),
|
||||||
|
strikeEndAt: z.coerce.date().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
candidatesRouter.patch("/:id", async (req, res) => {
|
||||||
|
const parsed = updateSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Invalid input" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.calendarCandidate.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = { ...existing, ...parsed.data };
|
||||||
|
|
||||||
|
// Already-approved items are live on the calendar — push the edit through
|
||||||
|
// to CalDAV before persisting, so the DB and calendar never drift apart.
|
||||||
|
// The strike entry (if any) is synced independently of whether the main
|
||||||
|
// event itself was auto-pushed or marked manual — it's always a real
|
||||||
|
// CalDAV event when the checkbox is on, since it's easy to forget by hand.
|
||||||
|
if (existing.status === "APPROVED") {
|
||||||
|
try {
|
||||||
|
const caldavData: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (existing.caldavEventUid && existing.caldavEventUrl) {
|
||||||
|
const result = await updateCaldavEvent(existing.caldavEventUid, existing.caldavEventUrl, existing.caldavEtag, {
|
||||||
|
title: merged.title,
|
||||||
|
description: merged.description,
|
||||||
|
location: merged.location,
|
||||||
|
startAt: merged.startAt,
|
||||||
|
endAt: merged.endAt,
|
||||||
|
allDay: merged.allDay,
|
||||||
|
});
|
||||||
|
caldavData.caldavEtag = result.etag;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(caldavData, await syncStrikeCaldav(existing, merged));
|
||||||
|
|
||||||
|
const updated = await prisma.calendarCandidate.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { ...parsed.data, ...caldavData },
|
||||||
|
});
|
||||||
|
res.json(updated);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
const status = err instanceof CaldavWriteError ? 502 : 500;
|
||||||
|
res.status(status).json({ error: err instanceof Error ? err.message : "Failed to update calendar event" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.calendarCandidate.update({ where: { id: existing.id }, data: parsed.data });
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
candidatesRouter.post("/:id/approve", async (req: AuthedRequest, res) => {
|
||||||
|
const existing = await prisma.calendarCandidate.findUnique({ where: { id: req.params.id as string } });
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing.status === "APPROVED") {
|
||||||
|
res.status(409).json({ error: "Already approved" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventInput = {
|
||||||
|
title: existing.title,
|
||||||
|
description: existing.description,
|
||||||
|
location: existing.location,
|
||||||
|
startAt: existing.startAt,
|
||||||
|
endAt: existing.endAt,
|
||||||
|
allDay: existing.allDay,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result =
|
||||||
|
existing.caldavEventUid && existing.caldavEventUrl
|
||||||
|
? await updateCaldavEvent(existing.caldavEventUid, existing.caldavEventUrl, existing.caldavEtag, eventInput)
|
||||||
|
: await createCaldavEvent(eventInput);
|
||||||
|
|
||||||
|
const strikeCaldav = await syncStrikeCaldav(existing, existing);
|
||||||
|
|
||||||
|
const updated = await prisma.calendarCandidate.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
status: "APPROVED",
|
||||||
|
approvedById: req.user!.id,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
rejectedAt: null,
|
||||||
|
caldavEventUid: result.uid,
|
||||||
|
caldavEventUrl: result.url,
|
||||||
|
caldavEtag: result.etag,
|
||||||
|
...strikeCaldav,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err instanceof CaldavWriteError ? 502 : 500;
|
||||||
|
res.status(status).json({ error: err instanceof Error ? err.message : "Failed to create calendar event" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// For items the business already put on the calendar themselves (by hand,
|
||||||
|
// or before adopting this tool) — records the review decision without
|
||||||
|
// writing a CalDAV event, so it doesn't create a duplicate.
|
||||||
|
candidatesRouter.post("/:id/mark-manual", async (req: AuthedRequest, res) => {
|
||||||
|
const existing = await prisma.calendarCandidate.findUnique({ where: { id: req.params.id as string } });
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing.status === "APPROVED") {
|
||||||
|
res.status(409).json({ error: "Already approved" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.calendarCandidate.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
status: "APPROVED",
|
||||||
|
approvedById: req.user!.id,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
rejectedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
candidatesRouter.post("/:id/reject", async (req, res) => {
|
||||||
|
const existing = await prisma.calendarCandidate.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existing.caldavEventUrl) {
|
||||||
|
await deleteCaldavEvent(existing.caldavEventUrl, existing.caldavEtag);
|
||||||
|
}
|
||||||
|
if (existing.strikeCaldavEventUrl) {
|
||||||
|
await deleteCaldavEvent(existing.strikeCaldavEventUrl, existing.strikeCaldavEtag);
|
||||||
|
}
|
||||||
|
const updated = await prisma.calendarCandidate.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
status: "REJECTED",
|
||||||
|
rejectedAt: new Date(),
|
||||||
|
caldavEventUid: null,
|
||||||
|
caldavEventUrl: null,
|
||||||
|
caldavEtag: null,
|
||||||
|
strikeCaldavEventUid: null,
|
||||||
|
strikeCaldavEventUrl: null,
|
||||||
|
strikeCaldavEtag: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err instanceof CaldavWriteError ? 502 : 500;
|
||||||
|
res.status(status).json({ error: err instanceof Error ? err.message : "Failed to remove calendar event" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
candidatesRouter.post("/:id/unreject", async (req, res) => {
|
||||||
|
const existing = await prisma.calendarCandidate.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await prisma.calendarCandidate.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { status: "PENDING", rejectedAt: null },
|
||||||
|
});
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
3
server/src/db.ts
Normal file
3
server/src/db.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient();
|
||||||
19
server/src/entrypoint.ts
Normal file
19
server/src/entrypoint.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const schemaPath = path.join(__dirname, "../prisma/schema.prisma");
|
||||||
|
|
||||||
|
function run(command: string, args: string[]) {
|
||||||
|
execFileSync(command, args, { stdio: "inherit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Running database migrations...");
|
||||||
|
run("npx", ["prisma", "migrate", "deploy", "--schema", schemaPath]);
|
||||||
|
|
||||||
|
console.log("Running first-run admin bootstrap...");
|
||||||
|
run("node", [path.join(__dirname, "bootstrap.js")]);
|
||||||
|
|
||||||
|
const { startServer } = await import("./server.js");
|
||||||
|
startServer();
|
||||||
65
server/src/env.ts
Normal file
65
server/src/env.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// In production, real env vars come from docker-compose's env_file. Locally,
|
||||||
|
// pick up a .env file if present (no dotenv dependency needed on Node 22+).
|
||||||
|
try {
|
||||||
|
process.loadEnvFile();
|
||||||
|
} catch {
|
||||||
|
// no .env file present — fine, assume env vars are set some other way
|
||||||
|
}
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
|
JWT_SECRET: z.string().min(16, "JWT_SECRET must be at least 16 characters"),
|
||||||
|
DATABASE_URL: z.string().min(1),
|
||||||
|
|
||||||
|
BOOTSTRAP_ADMIN_EMAIL: z.string().email(),
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(8),
|
||||||
|
BOOTSTRAP_ADMIN_NAME: z.string().default("Admin"),
|
||||||
|
|
||||||
|
// IANA timezone (e.g. "America/New_York") the business operates in. Used to
|
||||||
|
// interpret bare dates/times extracted from order notes (see
|
||||||
|
// square/lineItemParsing.ts) and as a fallback when Square doesn't supply
|
||||||
|
// an invoice-specific timezone. Defaults to UTC, which is almost certainly
|
||||||
|
// wrong for a real deployment — set this explicitly.
|
||||||
|
BUSINESS_TIMEZONE: z
|
||||||
|
.string()
|
||||||
|
.default("UTC")
|
||||||
|
.refine((tz) => DateTime.now().setZone(tz).isValid, "BUSINESS_TIMEZONE must be a valid IANA timezone name"),
|
||||||
|
|
||||||
|
SQUARE_ENVIRONMENT: z.enum(["sandbox", "production"]).default("sandbox"),
|
||||||
|
SQUARE_APP_ID: z.string().optional(),
|
||||||
|
SQUARE_ACCESS_TOKEN: z.string().min(1),
|
||||||
|
SQUARE_LOCATION_ID: z.string().optional(),
|
||||||
|
SYNC_INTERVAL_CRON: z.string().default("*/15 * * * *"),
|
||||||
|
SYNC_MAX_PAGES: z.coerce.number().default(10),
|
||||||
|
// Square's Invoices API has no date-range filter, so we page through
|
||||||
|
// invoices newest-first and stop once we're this many days back — without
|
||||||
|
// this, every sync would re-fetch every invoice ever created.
|
||||||
|
SYNC_INVOICE_LOOKBACK_DAYS: z.coerce.number().default(180),
|
||||||
|
PAYMENT_DEFAULT_DURATION_MINUTES: z.coerce.number().default(60),
|
||||||
|
SYNC_OVERLAP_MINUTES: z.coerce.number().default(60),
|
||||||
|
|
||||||
|
CALDAV_SERVER_URL: z.string().min(1),
|
||||||
|
CALDAV_CALENDAR_URL: z.string().min(1),
|
||||||
|
CALDAV_USERNAME: z.string().min(1),
|
||||||
|
CALDAV_PASSWORD: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
|
function loadEnv(): Env {
|
||||||
|
const parsed = envSchema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error("Invalid environment configuration:");
|
||||||
|
for (const issue of parsed.error.issues) {
|
||||||
|
console.error(` ${issue.path.join(".")}: ${issue.message}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = loadEnv();
|
||||||
9
server/src/healthcheck.ts
Normal file
9
server/src/healthcheck.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const port = process.env.PORT ?? "3000";
|
||||||
|
|
||||||
|
fetch(`http://127.0.0.1:${port}/api/health`)
|
||||||
|
.then((res) => {
|
||||||
|
process.exit(res.ok ? 0 : 1);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
27
server/src/scheduler.ts
Normal file
27
server/src/scheduler.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import cron from "node-cron";
|
||||||
|
import { env } from "./env.js";
|
||||||
|
import { runSync } from "./square/sync.js";
|
||||||
|
|
||||||
|
export function startScheduler() {
|
||||||
|
if (!cron.validate(env.SYNC_INTERVAL_CRON)) {
|
||||||
|
console.error(`Invalid SYNC_INTERVAL_CRON expression: ${env.SYNC_INTERVAL_CRON}. Scheduler not started.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cron.schedule(env.SYNC_INTERVAL_CRON, async () => {
|
||||||
|
try {
|
||||||
|
const result = await runSync("cron");
|
||||||
|
if (result.error) {
|
||||||
|
console.error("Scheduled Square sync finished with an error:", result.error);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`Scheduled Square sync complete: ${result.candidatesCreated} new candidate(s) from ${result.invoicesFetched} invoices / ${result.paymentsFetched} payments.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Scheduled Square sync threw:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Square sync scheduled: ${env.SYNC_INTERVAL_CRON}`);
|
||||||
|
}
|
||||||
50
server/src/server.ts
Normal file
50
server/src/server.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import { pinoHttp } from "pino-http";
|
||||||
|
import { env } from "./env.js";
|
||||||
|
import { authRouter } from "./auth/routes.js";
|
||||||
|
import { usersRouter } from "./users/routes.js";
|
||||||
|
import { candidatesRouter } from "./candidates/routes.js";
|
||||||
|
import { syncRouter } from "./square/routes.js";
|
||||||
|
import { startScheduler } from "./scheduler.js";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const webDistPath = path.join(__dirname, "../../web/dist");
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(helmet({ contentSecurityPolicy: false }));
|
||||||
|
app.use(cors({ origin: false })); // same-origin app; no cross-site API access needed
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(pinoHttp({ level: env.NODE_ENV === "production" ? "info" : "debug" }));
|
||||||
|
|
||||||
|
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false });
|
||||||
|
app.use("/api/auth/login", loginLimiter);
|
||||||
|
|
||||||
|
app.get("/api/health", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
app.use("/api/auth", authRouter);
|
||||||
|
app.use("/api/users", usersRouter);
|
||||||
|
app.use("/api/candidates", candidatesRouter);
|
||||||
|
app.use("/api/sync", syncRouter);
|
||||||
|
|
||||||
|
app.use(express.static(webDistPath));
|
||||||
|
app.get(/^(?!\/api).*/, (_req, res) => {
|
||||||
|
res.sendFile(path.join(webDistPath, "index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startServer() {
|
||||||
|
const app = createApp();
|
||||||
|
app.listen(env.PORT, () => {
|
||||||
|
console.log(`Server listening on port ${env.PORT} (${env.NODE_ENV})`);
|
||||||
|
});
|
||||||
|
startScheduler();
|
||||||
|
}
|
||||||
11
server/src/square/client.ts
Normal file
11
server/src/square/client.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { SquareClient, SquareEnvironment } from "square";
|
||||||
|
import { env } from "../env.js";
|
||||||
|
|
||||||
|
export const squareClient = new SquareClient({
|
||||||
|
token: env.SQUARE_ACCESS_TOKEN,
|
||||||
|
environment: env.SQUARE_ENVIRONMENT === "production" ? SquareEnvironment.Production : SquareEnvironment.Sandbox,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const squareLocationIds: string[] = env.SQUARE_LOCATION_ID
|
||||||
|
? env.SQUARE_LOCATION_ID.split(",").map((id) => id.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
339
server/src/square/lineItemParsing.ts
Normal file
339
server/src/square/lineItemParsing.ts
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
|
import type { OrderLineItem } from "square";
|
||||||
|
|
||||||
|
export type ScheduleQualifier = "before" | "after" | "by" | "at";
|
||||||
|
|
||||||
|
export interface ExtractedSchedule {
|
||||||
|
location?: string;
|
||||||
|
date?: { month: number; day: number; year?: number };
|
||||||
|
time?: { hour: number; minute: number; qualifier?: ScheduleQualifier };
|
||||||
|
endTime?: { hour: number; minute: number };
|
||||||
|
// The strike (teardown/pickup) is often on a different day and always
|
||||||
|
// treated as its own calendar entry — see resolveScheduleDate.
|
||||||
|
strikeDate?: { month: number; day: number; year?: number };
|
||||||
|
strikeTime?: { hour: number; minute: number; qualifier?: ScheduleQualifier };
|
||||||
|
strikeEndTime?: { hour: number; minute: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_LINE = /^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/;
|
||||||
|
|
||||||
|
// One side of a time. Accepts "5", "5:00", "5pm", "17:00", and also compact
|
||||||
|
// packed digits with no colon — "330pm" (3:30pm), "1030am" (10:30am) — which
|
||||||
|
// this business's notes actually use. A bare 1-2 digit run is a plain hour;
|
||||||
|
// 3-4 digits with no explicit ":MM" are read as HMM/HHMM.
|
||||||
|
const TIME_SIDE = `(\\d{1,4})(?::(\\d{2}))?\\s*(am|pm)?`;
|
||||||
|
// A time or a "-"-separated range, with an optional leading deadline word.
|
||||||
|
const TIME_OR_RANGE = `(before|after|by|at)?\\s*${TIME_SIDE}(?:\\s*-\\s*${TIME_SIDE})?`;
|
||||||
|
|
||||||
|
// A whole note line that's just a time/range ("9:30", "before 9:30", "6-7am").
|
||||||
|
const TIME_LINE = new RegExp(`^${TIME_OR_RANGE}$`, "i");
|
||||||
|
// Some notes put the date and time on the same line ("03/21 5pm-6pm") instead
|
||||||
|
// of separate lines.
|
||||||
|
const DATE_AND_TIME_LINE = new RegExp(`^(\\d{1,2})\\/(\\d{1,2})(?:\\/(\\d{2,4}))?\\s+${TIME_OR_RANGE}$`, "i");
|
||||||
|
// "Strike" (teardown/pickup) marker, optionally followed by a date, a time,
|
||||||
|
// both, or nothing (in which case the date follows on the next line).
|
||||||
|
const STRIKE_PREFIX = /^strike\b\s*[:\-]?\s*(.*)$/i;
|
||||||
|
|
||||||
|
// "05/01 Waterbury 6:00-7:00" — a bare M/D date, then freeform location text,
|
||||||
|
// then a time or time range at the end. Used to parse invoice titles, which
|
||||||
|
// this business sets to the real event schedule (separate from Square's own
|
||||||
|
// `dueDate`, which is just when payment is due and often unrelated).
|
||||||
|
const TITLE_DATE_PREFIX = /^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?\s+(.+)$/;
|
||||||
|
const TITLE_TRAILING_TIME = new RegExp(`^(.*?)\\s+${TIME_OR_RANGE}$`, "i");
|
||||||
|
|
||||||
|
/** Splits a packed digit run ("330" -> 3:30, "1030" -> 10:30); 1-2 digits is just a bare hour. */
|
||||||
|
function parseHourMinute(rawHour: string, explicitMinute: string | undefined): { hour: number; minute: number } {
|
||||||
|
if (explicitMinute !== undefined) return { hour: Number(rawHour), minute: Number(explicitMinute) };
|
||||||
|
if (rawHour.length >= 3) {
|
||||||
|
return { hour: Number(rawHour.slice(0, -2)), minute: Number(rawHour.slice(-2)) };
|
||||||
|
}
|
||||||
|
return { hour: Number(rawHour), minute: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads one TIME_SIDE match (hour/minute/meridiem) at `offset` in a regex match array. */
|
||||||
|
function readTimeSide(m: RegExpMatchArray, offset: number): { hour: number; minute: number; meridiem: string | undefined } | null {
|
||||||
|
if (!m[offset]) return null;
|
||||||
|
const { hour, minute } = parseHourMinute(m[offset], m[offset + 1]);
|
||||||
|
return { hour, minute, meridiem: m[offset + 2]?.toLowerCase() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a start/end time pair, cross-filling a meridiem from whichever side actually has one. */
|
||||||
|
function buildTimeAndEndTime(
|
||||||
|
qualifierRaw: string | undefined,
|
||||||
|
m: RegExpMatchArray,
|
||||||
|
offset: number,
|
||||||
|
): { time: NonNullable<ExtractedSchedule["time"]>; endTime: ExtractedSchedule["endTime"] } {
|
||||||
|
const start = readTimeSide(m, offset)!;
|
||||||
|
const end = readTimeSide(m, offset + 3);
|
||||||
|
const sharedMeridiem = start.meridiem ?? end?.meridiem;
|
||||||
|
|
||||||
|
const qualifier = qualifierRaw?.toLowerCase() as ScheduleQualifier | undefined;
|
||||||
|
const time = { hour: to24Hour(start.hour, start.meridiem ?? sharedMeridiem), minute: start.minute, qualifier };
|
||||||
|
const endTime = end ? { hour: to24Hour(end.hour, end.meridiem ?? sharedMeridiem), minute: end.minute } : undefined;
|
||||||
|
|
||||||
|
return { time, endTime };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the business's "Delivery/Strike per Mile" catalog item (and similar
|
||||||
|
* mileage-based delivery fees) by name, regardless of exact wording.
|
||||||
|
*/
|
||||||
|
export function isDeliveryOrStrikeLineItem(item: OrderLineItem): boolean {
|
||||||
|
const name = (item.name ?? "").toLowerCase();
|
||||||
|
if (!name.includes("mile")) return false;
|
||||||
|
return name.includes("delivery") || name.includes("strike");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logistics/fee line items (delivery, strike/teardown, setup) — real charges,
|
||||||
|
* but not "what was ordered," so they're left out of the calendar entry's
|
||||||
|
* description. Broader than isDeliveryOrStrikeLineItem (which is specifically
|
||||||
|
* about finding the schedule note): this also catches flat fees like
|
||||||
|
* "Delivery Base and Setup" that don't mention mileage at all.
|
||||||
|
*/
|
||||||
|
export function isLogisticsLineItem(item: OrderLineItem): boolean {
|
||||||
|
const name = (item.name ?? "").toLowerCase();
|
||||||
|
return name.includes("delivery") || name.includes("strike") || name.includes("setup");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the freeform `note` field of a delivery/strike line item, where this
|
||||||
|
* business enters one piece of info per line: address line(s), city, venue
|
||||||
|
* name, a bare M/D date, and a time (optionally "before"/"after"/"by" a
|
||||||
|
* deadline). Order and presence of each line can vary, so each line is
|
||||||
|
* classified independently rather than by fixed position.
|
||||||
|
*
|
||||||
|
* A "strike" line (teardown/pickup) is parsed separately from the main
|
||||||
|
* date/time — real notes put it on its own day as often as not ("Strike\n
|
||||||
|
* 08/16", "strike 6/12 anytime after 1pm"), so it's tracked as a candidate
|
||||||
|
* for its own calendar entry rather than folded into the main one.
|
||||||
|
*/
|
||||||
|
export function extractScheduleFromNote(note: string | null | undefined): ExtractedSchedule | null {
|
||||||
|
if (!note) return null;
|
||||||
|
|
||||||
|
const locationParts: string[] = [];
|
||||||
|
let date: ExtractedSchedule["date"];
|
||||||
|
let time: ExtractedSchedule["time"];
|
||||||
|
let endTime: ExtractedSchedule["endTime"];
|
||||||
|
let strikeDate: ExtractedSchedule["strikeDate"];
|
||||||
|
let strikeTime: ExtractedSchedule["strikeTime"];
|
||||||
|
let strikeEndTime: ExtractedSchedule["strikeEndTime"];
|
||||||
|
let expectingStrikeDate = false;
|
||||||
|
|
||||||
|
for (const rawLine of note.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
const strikeMatch = line.match(STRIKE_PREFIX);
|
||||||
|
if (strikeMatch) {
|
||||||
|
// Strip filler words like "anytime" wherever they sit before a
|
||||||
|
// qualifier ("6/12 anytime after 1pm"), not just at the very start.
|
||||||
|
const remainder = strikeMatch[1].trim().replace(/\banytime\s+(?=before\b|after\b|by\b|at\b)/i, "");
|
||||||
|
expectingStrikeDate = false;
|
||||||
|
|
||||||
|
if (!remainder) {
|
||||||
|
expectingStrikeDate = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dtMatch = remainder.match(DATE_AND_TIME_LINE);
|
||||||
|
if (dtMatch) {
|
||||||
|
strikeDate = { month: Number(dtMatch[1]), day: Number(dtMatch[2]), year: dtMatch[3] ? normalizeYear(Number(dtMatch[3])) : undefined };
|
||||||
|
({ time: strikeTime, endTime: strikeEndTime } = buildTimeAndEndTime(dtMatch[4], dtMatch, 5));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dMatch = remainder.match(DATE_LINE);
|
||||||
|
if (dMatch) {
|
||||||
|
strikeDate = { month: Number(dMatch[1]), day: Number(dMatch[2]), year: dMatch[3] ? normalizeYear(Number(dMatch[3])) : undefined };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const tMatch = remainder.match(TIME_LINE);
|
||||||
|
if (tMatch) {
|
||||||
|
({ time: strikeTime, endTime: strikeEndTime } = buildTimeAndEndTime(tMatch[1], tMatch, 2));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Couldn't parse what follows "strike" — skip rather than guess wrong.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectingStrikeDate) {
|
||||||
|
expectingStrikeDate = false;
|
||||||
|
const dMatch = line.match(DATE_LINE);
|
||||||
|
if (dMatch) {
|
||||||
|
strikeDate = { month: Number(dMatch[1]), day: Number(dMatch[2]), year: dMatch[3] ? normalizeYear(Number(dMatch[3])) : undefined };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Next line wasn't a date after all — fall through to normal handling.
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateAndTimeMatch = line.match(DATE_AND_TIME_LINE);
|
||||||
|
if (dateAndTimeMatch) {
|
||||||
|
date = {
|
||||||
|
month: Number(dateAndTimeMatch[1]),
|
||||||
|
day: Number(dateAndTimeMatch[2]),
|
||||||
|
year: dateAndTimeMatch[3] ? normalizeYear(Number(dateAndTimeMatch[3])) : undefined,
|
||||||
|
};
|
||||||
|
({ time, endTime } = buildTimeAndEndTime(dateAndTimeMatch[4], dateAndTimeMatch, 5));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateMatch = line.match(DATE_LINE);
|
||||||
|
if (dateMatch) {
|
||||||
|
date = {
|
||||||
|
month: Number(dateMatch[1]),
|
||||||
|
day: Number(dateMatch[2]),
|
||||||
|
year: dateMatch[3] ? normalizeYear(Number(dateMatch[3])) : undefined,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeMatch = line.match(TIME_LINE);
|
||||||
|
if (timeMatch) {
|
||||||
|
({ time, endTime } = buildTimeAndEndTime(timeMatch[1], timeMatch, 2));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything else on its own line is treated as part of the delivery
|
||||||
|
// address/venue (street, city, venue name, etc.).
|
||||||
|
locationParts.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationParts.length === 0 && !date && !time && !strikeDate && !strikeTime) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
location: locationParts.length > 0 ? locationParts.join(", ") : undefined,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
endTime,
|
||||||
|
strikeDate,
|
||||||
|
strikeTime,
|
||||||
|
strikeEndTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an invoice title like "05/01 Waterbury 6:00-7:00" into a schedule:
|
||||||
|
* a leading M/D date, freeform location text, and a trailing time or time
|
||||||
|
* range. Returns null if the title doesn't start with a recognizable date
|
||||||
|
* (most invoice titles won't match, which is expected).
|
||||||
|
*/
|
||||||
|
export function extractScheduleFromTitle(title: string | null | undefined): ExtractedSchedule | null {
|
||||||
|
if (!title) return null;
|
||||||
|
|
||||||
|
const dateMatch = title.trim().match(TITLE_DATE_PREFIX);
|
||||||
|
if (!dateMatch) return null;
|
||||||
|
|
||||||
|
const [, monthStr, dayStr, yearStr, rest] = dateMatch;
|
||||||
|
const date = { month: Number(monthStr), day: Number(dayStr), year: yearStr ? normalizeYear(Number(yearStr)) : undefined };
|
||||||
|
|
||||||
|
const timeMatch = rest.match(TITLE_TRAILING_TIME);
|
||||||
|
if (!timeMatch) {
|
||||||
|
return { location: rest.trim() || undefined, date };
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationPart = timeMatch[1];
|
||||||
|
const { time, endTime } = buildTimeAndEndTime(timeMatch[2], timeMatch, 3);
|
||||||
|
|
||||||
|
return { location: locationPart.trim() || undefined, date, time, endTime };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeYear(year: number): number {
|
||||||
|
return year < 100 ? 2000 + year : year;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No am/pm given ("before 9:30") — guess using typical delivery/strike hours; the human reviewer can correct it. */
|
||||||
|
function to24Hour(hour: number, meridiem: string | undefined): number {
|
||||||
|
// "15:00", "03:00" written as 24-hour — unambiguous, no guessing needed.
|
||||||
|
if (hour === 0 || hour >= 13) return hour;
|
||||||
|
if (meridiem === "pm") return hour === 12 ? 12 : hour + 12;
|
||||||
|
if (meridiem === "am") return hour === 12 ? 0 : hour;
|
||||||
|
// No meridiem, hour 1-12: 6-11 -> assume AM, 12 and 1-5 -> assume PM.
|
||||||
|
if (hour >= 6 && hour <= 11) return hour;
|
||||||
|
if (hour === 12) return 12;
|
||||||
|
return hour + 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedSchedule {
|
||||||
|
date: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
allDay: boolean;
|
||||||
|
qualifierNote?: string;
|
||||||
|
strikeDate?: Date;
|
||||||
|
strikeEndDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a parsed schedule to a concrete instant, interpreting the bare
|
||||||
|
* date/time in `zone` (the business's local timezone) rather than whatever
|
||||||
|
* timezone the server process happens to run in — critical since Docker
|
||||||
|
* containers default to UTC, which would otherwise silently shift every
|
||||||
|
* extracted appointment time by several hours.
|
||||||
|
*/
|
||||||
|
export function resolveScheduleDate(schedule: ExtractedSchedule, now: DateTime, zone: string): ResolvedSchedule | null {
|
||||||
|
if (!schedule.date) return null;
|
||||||
|
|
||||||
|
const year = schedule.date.year ?? inferYear(schedule.date.month, schedule.date.day, now);
|
||||||
|
const hour = schedule.time?.hour ?? 0;
|
||||||
|
const minute = schedule.time?.minute ?? 0;
|
||||||
|
const dt = DateTime.fromObject({ year, month: schedule.date.month, day: schedule.date.day, hour, minute }, { zone });
|
||||||
|
|
||||||
|
let endDt: DateTime | undefined;
|
||||||
|
if (schedule.endTime) {
|
||||||
|
endDt = DateTime.fromObject(
|
||||||
|
{ year, month: schedule.date.month, day: schedule.date.day, hour: schedule.endTime.hour, minute: schedule.endTime.minute },
|
||||||
|
{ zone },
|
||||||
|
);
|
||||||
|
// "11:30-1:00" style ranges cross into PM without repeating am/pm — if the
|
||||||
|
// parsed end lands before the start, assume it's actually later the same day.
|
||||||
|
if (endDt.isValid && dt.isValid && endDt <= dt) endDt = endDt.plus({ hours: 12 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualifierNote = schedule.time?.qualifier
|
||||||
|
? `${capitalize(schedule.time.qualifier)} ${formatTime(schedule.time.hour, schedule.time.minute)}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let strikeDt: DateTime | undefined;
|
||||||
|
let strikeEndDt: DateTime | undefined;
|
||||||
|
if (schedule.strikeDate || schedule.strikeTime) {
|
||||||
|
// No explicit strike date means same day as the main event; no explicit
|
||||||
|
// strike time means same time-of-day as the main event's start.
|
||||||
|
const sMonth = schedule.strikeDate?.month ?? schedule.date.month;
|
||||||
|
const sDay = schedule.strikeDate?.day ?? schedule.date.day;
|
||||||
|
const sYear = schedule.strikeDate?.year ?? (schedule.strikeDate ? inferYear(sMonth, sDay, now) : year);
|
||||||
|
const sHour = schedule.strikeTime?.hour ?? hour;
|
||||||
|
const sMinute = schedule.strikeTime?.minute ?? minute;
|
||||||
|
|
||||||
|
strikeDt = DateTime.fromObject({ year: sYear, month: sMonth, day: sDay, hour: sHour, minute: sMinute }, { zone });
|
||||||
|
if (schedule.strikeEndTime) {
|
||||||
|
strikeEndDt = DateTime.fromObject(
|
||||||
|
{ year: sYear, month: sMonth, day: sDay, hour: schedule.strikeEndTime.hour, minute: schedule.strikeEndTime.minute },
|
||||||
|
{ zone },
|
||||||
|
);
|
||||||
|
if (strikeEndDt.isValid && strikeDt.isValid && strikeEndDt <= strikeDt) strikeEndDt = strikeEndDt.plus({ hours: 12 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: dt.isValid ? dt.toJSDate() : now.toJSDate(),
|
||||||
|
endDate: endDt?.isValid ? endDt.toJSDate() : undefined,
|
||||||
|
allDay: !schedule.time,
|
||||||
|
qualifierNote,
|
||||||
|
strikeDate: strikeDt?.isValid ? strikeDt.toJSDate() : undefined,
|
||||||
|
strikeEndDate: strikeEndDt?.isValid ? strikeEndDt.toJSDate() : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferYear(month: number, day: number, now: DateTime): number {
|
||||||
|
const candidate = now.set({ month, day, hour: 0, minute: 0, second: 0, millisecond: 0 });
|
||||||
|
const ninetyDaysAgo = now.minus({ days: 90 });
|
||||||
|
return candidate < ninetyDaysAgo ? now.year + 1 : now.year;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(hour: number, minute: number): string {
|
||||||
|
const period = hour >= 12 ? "PM" : "AM";
|
||||||
|
const displayHour = hour % 12 === 0 ? 12 : hour % 12;
|
||||||
|
return `${displayHour}:${String(minute).padStart(2, "0")} ${period}`;
|
||||||
|
}
|
||||||
250
server/src/square/mapping.ts
Normal file
250
server/src/square/mapping.ts
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
|
import type { Invoice, Order, OrderLineItem, Payment } from "square";
|
||||||
|
import { env } from "../env.js";
|
||||||
|
import {
|
||||||
|
extractScheduleFromNote,
|
||||||
|
extractScheduleFromTitle,
|
||||||
|
isDeliveryOrStrikeLineItem,
|
||||||
|
isLogisticsLineItem,
|
||||||
|
resolveScheduleDate,
|
||||||
|
type ExtractedSchedule,
|
||||||
|
} from "./lineItemParsing.js";
|
||||||
|
|
||||||
|
export interface CandidateInput {
|
||||||
|
sourceType: "SQUARE_INVOICE" | "SQUARE_PAYMENT";
|
||||||
|
squareId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
startAt: Date;
|
||||||
|
endAt: Date;
|
||||||
|
allDay: boolean;
|
||||||
|
location?: string;
|
||||||
|
customerName?: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
amountCents?: number;
|
||||||
|
currency?: string;
|
||||||
|
squareStatus?: string;
|
||||||
|
squareCreatedAt?: Date;
|
||||||
|
hasStrike?: boolean;
|
||||||
|
strikeStartAt?: Date;
|
||||||
|
strikeEndAt?: Date;
|
||||||
|
rawPayload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKIPPED_INVOICE_STATUSES = new Set(["DRAFT", "CANCELED"]);
|
||||||
|
|
||||||
|
function moneyToCents(money: { amount?: bigint | null; currency?: string } | undefined): number | undefined {
|
||||||
|
if (money?.amount === undefined || money.amount === null) return undefined;
|
||||||
|
return Number(money.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLineItem(item: OrderLineItem): string {
|
||||||
|
const parts = [item.name ?? "Item"];
|
||||||
|
if (item.variationName) parts.push(`(${item.variationName})`);
|
||||||
|
let line = `${parts.join(" ")} x${item.quantity}`;
|
||||||
|
if (item.note) line += ` — ${item.note}`;
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar entry's description: the customer's phone number first (per
|
||||||
|
* the business's request, so it's the first thing visible), then what was
|
||||||
|
* actually ordered — deliberately excluding Square's own invoice
|
||||||
|
* `description` field, dollar amounts, and logistics/fee line items
|
||||||
|
* (delivery, strike, setup), none of which belong on a calendar entry.
|
||||||
|
*/
|
||||||
|
function buildDescription(phone: string | null | undefined, order: Order | null | undefined): string | undefined {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (phone) parts.push(phone);
|
||||||
|
const lineItems = (order?.lineItems ?? []).filter((item) => !isLogisticsLineItem(item));
|
||||||
|
if (lineItems.length > 0) {
|
||||||
|
parts.push(lineItems.map(formatLineItem).join("\n"));
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a parsed schedule (from an invoice title or an order line item
|
||||||
|
* note) on top of a candidate: overrides location and start/end time when
|
||||||
|
* the schedule includes them. An explicit end time (from a "H:MM-H:MM"
|
||||||
|
* range) is used directly; otherwise a single time defaults to a 1-hour slot.
|
||||||
|
*/
|
||||||
|
function applyExtractedSchedule(
|
||||||
|
candidate: CandidateInput,
|
||||||
|
schedule: ExtractedSchedule | null,
|
||||||
|
referenceDate: DateTime,
|
||||||
|
zone: string,
|
||||||
|
noteLabel: string,
|
||||||
|
): CandidateInput {
|
||||||
|
if (!schedule) return candidate;
|
||||||
|
|
||||||
|
// Anchor "what year did they mean" to when the invoice/payment itself was
|
||||||
|
// created, not to whenever the sync job happens to run — otherwise
|
||||||
|
// re-syncing an old invoice pushes its date a full year into the future
|
||||||
|
// just because it looks "far in the past" relative to today's wall clock.
|
||||||
|
const resolved = schedule.date ? resolveScheduleDate(schedule, referenceDate, zone) : null;
|
||||||
|
if (!resolved) {
|
||||||
|
return schedule.location ? { ...candidate, location: schedule.location } : candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endAt = resolved.endDate
|
||||||
|
? resolved.endDate
|
||||||
|
: DateTime.fromJSDate(resolved.date)
|
||||||
|
.plus(resolved.allDay ? { days: 1 } : { hours: 1 })
|
||||||
|
.toJSDate();
|
||||||
|
|
||||||
|
const descriptionParts = [candidate.description];
|
||||||
|
if (resolved.qualifierNote) {
|
||||||
|
descriptionParts.push(`${noteLabel} (verify): ${resolved.qualifierNote}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A detected strike (teardown/pickup) becomes its own suggested calendar
|
||||||
|
// entry — defaulting to a 1-hour slot, same as the main event, when the
|
||||||
|
// note didn't give an explicit end time.
|
||||||
|
const strikeOverrides = resolved.strikeDate
|
||||||
|
? {
|
||||||
|
hasStrike: true,
|
||||||
|
strikeStartAt: resolved.strikeDate,
|
||||||
|
strikeEndAt: resolved.strikeEndDate ?? DateTime.fromJSDate(resolved.strikeDate).plus({ hours: 1 }).toJSDate(),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...candidate,
|
||||||
|
location: schedule.location ?? candidate.location,
|
||||||
|
startAt: resolved.date,
|
||||||
|
endAt,
|
||||||
|
allDay: resolved.allDay,
|
||||||
|
description: descriptionParts.filter(Boolean).join("\n") || undefined,
|
||||||
|
...strikeOverrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapInvoiceToCandidate(
|
||||||
|
invoice: Invoice,
|
||||||
|
order?: Order | null,
|
||||||
|
customer?: { phoneNumber?: string | null } | null,
|
||||||
|
): CandidateInput | null {
|
||||||
|
if (!invoice.id) return null;
|
||||||
|
if (invoice.status && SKIPPED_INVOICE_STATUSES.has(invoice.status)) return null;
|
||||||
|
|
||||||
|
const primaryRequest = invoice.paymentRequests?.[0];
|
||||||
|
const timezone = invoice.timezone || env.BUSINESS_TIMEZONE;
|
||||||
|
|
||||||
|
let startAt: DateTime;
|
||||||
|
let allDay: boolean;
|
||||||
|
if (primaryRequest?.dueDate) {
|
||||||
|
startAt = DateTime.fromISO(primaryRequest.dueDate, { zone: timezone }).startOf("day");
|
||||||
|
allDay = true;
|
||||||
|
} else if (invoice.createdAt) {
|
||||||
|
startAt = DateTime.fromISO(invoice.createdAt, { zone: timezone });
|
||||||
|
allDay = false;
|
||||||
|
} else {
|
||||||
|
startAt = DateTime.now().setZone(timezone);
|
||||||
|
allDay = false;
|
||||||
|
}
|
||||||
|
if (!startAt.isValid) {
|
||||||
|
startAt = DateTime.now().setZone(timezone);
|
||||||
|
}
|
||||||
|
const endAt = allDay ? startAt.plus({ days: 1 }) : startAt.plus({ hours: 1 });
|
||||||
|
|
||||||
|
const recipient = invoice.primaryRecipient;
|
||||||
|
const customerName =
|
||||||
|
[recipient?.givenName, recipient?.familyName].filter(Boolean).join(" ").trim() ||
|
||||||
|
recipient?.companyName ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
const amountCents = moneyToCents(primaryRequest?.computedAmountMoney);
|
||||||
|
const currency = primaryRequest?.computedAmountMoney?.currency;
|
||||||
|
const phone = recipient?.phoneNumber || customer?.phoneNumber || undefined;
|
||||||
|
|
||||||
|
const candidate: CandidateInput = {
|
||||||
|
sourceType: "SQUARE_INVOICE",
|
||||||
|
squareId: invoice.id,
|
||||||
|
title: customerName || invoice.title || `Invoice ${invoice.invoiceNumber ?? invoice.id.slice(0, 8)}`,
|
||||||
|
description: buildDescription(phone, order),
|
||||||
|
startAt: startAt.toJSDate(),
|
||||||
|
endAt: endAt.toJSDate(),
|
||||||
|
allDay,
|
||||||
|
customerName,
|
||||||
|
customerEmail: recipient?.emailAddress ?? undefined,
|
||||||
|
amountCents,
|
||||||
|
currency,
|
||||||
|
squareStatus: invoice.status,
|
||||||
|
squareCreatedAt: invoice.createdAt ? DateTime.fromISO(invoice.createdAt).toJSDate() : undefined,
|
||||||
|
rawPayload: invoice as unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoiceReferenceRaw = invoice.createdAt ? DateTime.fromISO(invoice.createdAt, { zone: timezone }) : DateTime.now().setZone(timezone);
|
||||||
|
const invoiceReference = invoiceReferenceRaw.isValid ? invoiceReferenceRaw : DateTime.now().setZone(timezone);
|
||||||
|
|
||||||
|
// Titles like "05/01 Waterbury 6:00-7:00" carry the real event schedule —
|
||||||
|
// separate from (and often unrelated to) Square's own `dueDate`, which is
|
||||||
|
// just when payment is due. Applied first; a delivery/strike order note,
|
||||||
|
// if present, is more specific and gets the final say.
|
||||||
|
const withTitleSchedule = applyExtractedSchedule(
|
||||||
|
candidate,
|
||||||
|
extractScheduleFromTitle(invoice.title),
|
||||||
|
invoiceReference,
|
||||||
|
timezone,
|
||||||
|
"Schedule (from invoice title)",
|
||||||
|
);
|
||||||
|
|
||||||
|
const lineItem = order?.lineItems?.find(isDeliveryOrStrikeLineItem);
|
||||||
|
return applyExtractedSchedule(
|
||||||
|
withTitleSchedule,
|
||||||
|
extractScheduleFromNote(lineItem?.note),
|
||||||
|
invoiceReference,
|
||||||
|
timezone,
|
||||||
|
"Delivery/strike time (from order note)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPaymentToCandidate(
|
||||||
|
payment: Payment,
|
||||||
|
order?: Order | null,
|
||||||
|
customer?: { givenName?: string | null; familyName?: string | null; phoneNumber?: string | null } | null,
|
||||||
|
): CandidateInput | null {
|
||||||
|
if (!payment.id) return null;
|
||||||
|
if (payment.status !== "COMPLETED") return null;
|
||||||
|
|
||||||
|
let startAt = payment.createdAt ? DateTime.fromISO(payment.createdAt) : DateTime.now();
|
||||||
|
if (!startAt.isValid) startAt = DateTime.now();
|
||||||
|
const endAt = startAt.plus({ minutes: env.PAYMENT_DEFAULT_DURATION_MINUTES });
|
||||||
|
|
||||||
|
const amountCents = moneyToCents(payment.totalMoney) ?? moneyToCents(payment.amountMoney);
|
||||||
|
const currency = payment.totalMoney?.currency ?? payment.amountMoney?.currency;
|
||||||
|
|
||||||
|
const customerName = [customer?.givenName, customer?.familyName].filter(Boolean).join(" ").trim() || undefined;
|
||||||
|
|
||||||
|
const candidate: CandidateInput = {
|
||||||
|
sourceType: "SQUARE_PAYMENT",
|
||||||
|
squareId: payment.id,
|
||||||
|
title: customerName || (payment.buyerEmailAddress ? `Payment from ${payment.buyerEmailAddress}` : `Square Payment ${payment.id.slice(0, 8)}`),
|
||||||
|
description: buildDescription(customer?.phoneNumber, order),
|
||||||
|
startAt: startAt.toJSDate(),
|
||||||
|
endAt: endAt.toJSDate(),
|
||||||
|
allDay: false,
|
||||||
|
customerName,
|
||||||
|
customerEmail: payment.buyerEmailAddress ?? undefined,
|
||||||
|
amountCents,
|
||||||
|
currency,
|
||||||
|
squareStatus: payment.status,
|
||||||
|
squareCreatedAt: payment.createdAt ? DateTime.fromISO(payment.createdAt).toJSDate() : undefined,
|
||||||
|
rawPayload: payment as unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
const paymentReferenceRaw = payment.createdAt
|
||||||
|
? DateTime.fromISO(payment.createdAt, { zone: env.BUSINESS_TIMEZONE })
|
||||||
|
: DateTime.now().setZone(env.BUSINESS_TIMEZONE);
|
||||||
|
const paymentReference = paymentReferenceRaw.isValid ? paymentReferenceRaw : DateTime.now().setZone(env.BUSINESS_TIMEZONE);
|
||||||
|
|
||||||
|
const lineItem = order?.lineItems?.find(isDeliveryOrStrikeLineItem);
|
||||||
|
return applyExtractedSchedule(
|
||||||
|
candidate,
|
||||||
|
extractScheduleFromNote(lineItem?.note),
|
||||||
|
paymentReference,
|
||||||
|
env.BUSINESS_TIMEZONE,
|
||||||
|
"Delivery/strike time (from order note)",
|
||||||
|
);
|
||||||
|
}
|
||||||
21
server/src/square/routes.ts
Normal file
21
server/src/square/routes.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { prisma } from "../db.js";
|
||||||
|
import { requireAuth } from "../auth/middleware.js";
|
||||||
|
import { runSync } from "./sync.js";
|
||||||
|
|
||||||
|
export const syncRouter = Router();
|
||||||
|
syncRouter.use(requireAuth);
|
||||||
|
|
||||||
|
syncRouter.post("/", async (_req, res) => {
|
||||||
|
const result = await runSync("manual");
|
||||||
|
if (result.error) {
|
||||||
|
res.status(502).json(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
syncRouter.get("/last", async (_req, res) => {
|
||||||
|
const last = await prisma.syncRun.findFirst({ orderBy: { startedAt: "desc" } });
|
||||||
|
res.json(last ?? null);
|
||||||
|
});
|
||||||
203
server/src/square/sync.ts
Normal file
203
server/src/square/sync.ts
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
|
import type { Customer, Order } from "square";
|
||||||
|
import { prisma } from "../db.js";
|
||||||
|
import { env } from "../env.js";
|
||||||
|
import { squareClient, squareLocationIds } from "./client.js";
|
||||||
|
import { mapInvoiceToCandidate, mapPaymentToCandidate, type CandidateInput } from "./mapping.js";
|
||||||
|
|
||||||
|
const MAX_ITEMS_PER_SOURCE = env.SYNC_MAX_PAGES * 100;
|
||||||
|
|
||||||
|
async function resolveLocationIds(): Promise<string[]> {
|
||||||
|
if (squareLocationIds.length > 0) return squareLocationIds;
|
||||||
|
const res = await squareClient.locations.list();
|
||||||
|
return (res.locations ?? []).map((loc) => loc.id).filter((id): id is string => Boolean(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caches order lookups within a single sync run (invoices/payments often share an order). */
|
||||||
|
function createOrderResolver() {
|
||||||
|
const cache = new Map<string, Order | null>();
|
||||||
|
return async function resolveOrder(orderId: string | null | undefined): Promise<Order | null> {
|
||||||
|
if (!orderId) return null;
|
||||||
|
if (cache.has(orderId)) return cache.get(orderId) ?? null;
|
||||||
|
try {
|
||||||
|
const res = await squareClient.orders.get({ orderId });
|
||||||
|
const order = res.order ?? null;
|
||||||
|
cache.set(orderId, order);
|
||||||
|
return order;
|
||||||
|
} catch {
|
||||||
|
cache.set(orderId, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caches customer lookups within a single sync run. */
|
||||||
|
function createCustomerResolver() {
|
||||||
|
const cache = new Map<string, Customer | null>();
|
||||||
|
return async function resolveCustomer(customerId: string | null | undefined): Promise<Customer | null> {
|
||||||
|
if (!customerId) return null;
|
||||||
|
if (cache.has(customerId)) return cache.get(customerId) ?? null;
|
||||||
|
try {
|
||||||
|
const res = await squareClient.customers.get({ customerId });
|
||||||
|
const customer = res.customer ?? null;
|
||||||
|
cache.set(customerId, customer);
|
||||||
|
return customer;
|
||||||
|
} catch {
|
||||||
|
cache.set(customerId, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertCandidate(input: CandidateInput): Promise<boolean> {
|
||||||
|
const existing = await prisma.calendarCandidate.findUnique({
|
||||||
|
where: {
|
||||||
|
square_source_unique: {
|
||||||
|
sourceType: input.sourceType,
|
||||||
|
squareId: input.squareId,
|
||||||
|
squareEnvironment: env.SQUARE_ENVIRONMENT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.calendarCandidate.create({
|
||||||
|
data: {
|
||||||
|
sourceType: input.sourceType,
|
||||||
|
squareId: input.squareId,
|
||||||
|
squareEnvironment: env.SQUARE_ENVIRONMENT,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
startAt: input.startAt,
|
||||||
|
endAt: input.endAt,
|
||||||
|
allDay: input.allDay,
|
||||||
|
location: input.location,
|
||||||
|
customerName: input.customerName,
|
||||||
|
customerEmail: input.customerEmail,
|
||||||
|
amountCents: input.amountCents,
|
||||||
|
currency: input.currency,
|
||||||
|
squareStatus: input.squareStatus,
|
||||||
|
squareCreatedAt: input.squareCreatedAt,
|
||||||
|
hasStrike: input.hasStrike ?? false,
|
||||||
|
strikeStartAt: input.strikeStartAt,
|
||||||
|
strikeEndAt: input.strikeEndAt,
|
||||||
|
rawPayload: input.rawPayload as object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once a human has approved or rejected a candidate, freeze it — a re-sync
|
||||||
|
// (e.g. Square-side edit) should not silently overwrite a reviewed record.
|
||||||
|
if (existing.status === "PENDING") {
|
||||||
|
await prisma.calendarCandidate.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
startAt: input.startAt,
|
||||||
|
endAt: input.endAt,
|
||||||
|
allDay: input.allDay,
|
||||||
|
location: input.location,
|
||||||
|
customerName: input.customerName,
|
||||||
|
customerEmail: input.customerEmail,
|
||||||
|
amountCents: input.amountCents,
|
||||||
|
currency: input.currency,
|
||||||
|
squareStatus: input.squareStatus,
|
||||||
|
squareCreatedAt: input.squareCreatedAt,
|
||||||
|
hasStrike: input.hasStrike ?? false,
|
||||||
|
strikeStartAt: input.strikeStartAt,
|
||||||
|
strikeEndAt: input.strikeEndAt,
|
||||||
|
rawPayload: input.rawPayload as object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSync(trigger: "cron" | "manual") {
|
||||||
|
const run = await prisma.syncRun.create({ data: { trigger } });
|
||||||
|
|
||||||
|
let invoicesFetched = 0;
|
||||||
|
let paymentsFetched = 0;
|
||||||
|
let candidatesCreated = 0;
|
||||||
|
let error: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Cron runs incrementally (only payments since the last successful sync)
|
||||||
|
// to stay cheap. A manually-triggered sync instead does a full refresh
|
||||||
|
// within the lookback window — the whole point of a manual "Sync now" is
|
||||||
|
// to catch up everything, including PENDING candidates that were created
|
||||||
|
// by older mapping logic and would otherwise never get re-touched once
|
||||||
|
// they fall outside the incremental window.
|
||||||
|
let beginTime: string | undefined;
|
||||||
|
if (trigger === "manual") {
|
||||||
|
beginTime = DateTime.now().minus({ days: env.SYNC_INVOICE_LOOKBACK_DAYS }).toISO() ?? undefined;
|
||||||
|
} else {
|
||||||
|
const lastSuccessful = await prisma.syncRun.findFirst({
|
||||||
|
where: { error: null, finishedAt: { not: null } },
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
});
|
||||||
|
beginTime = lastSuccessful
|
||||||
|
? DateTime.fromJSDate(lastSuccessful.startedAt).minus({ minutes: env.SYNC_OVERLAP_MINUTES }).toISO() ?? undefined
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationIds = await resolveLocationIds();
|
||||||
|
const resolveOrder = createOrderResolver();
|
||||||
|
const resolveCustomer = createCustomerResolver();
|
||||||
|
|
||||||
|
const invoiceCutoff = DateTime.now().minus({ days: env.SYNC_INVOICE_LOOKBACK_DAYS });
|
||||||
|
|
||||||
|
for (const locationId of locationIds) {
|
||||||
|
// Square's Invoices API has no date filter and no reliable sort-by-
|
||||||
|
// createdAt guarantee (INVOICE_SORT_DATE can be keyed off dueDate
|
||||||
|
// instead), so an early-stop-once-we-look-old-enough optimization can
|
||||||
|
// skip invoices that are actually within the window. Simpler and
|
||||||
|
// correct: page through everything (bounded by MAX_ITEMS_PER_SOURCE,
|
||||||
|
// which already comfortably covers this business's invoice volume)
|
||||||
|
// and just skip creating/updating candidates for ones outside the
|
||||||
|
// lookback window.
|
||||||
|
const invoicePage = await squareClient.invoices.list({ locationId, limit: 100 });
|
||||||
|
for await (const invoice of invoicePage) {
|
||||||
|
invoicesFetched++;
|
||||||
|
const referenceDate = invoice.createdAt ? DateTime.fromISO(invoice.createdAt) : null;
|
||||||
|
if (referenceDate?.isValid && referenceDate < invoiceCutoff) {
|
||||||
|
if (invoicesFetched >= MAX_ITEMS_PER_SOURCE) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const order = await resolveOrder(invoice.orderId);
|
||||||
|
// Invoices usually carry the recipient's phone directly; only fall
|
||||||
|
// back to a customer lookup when they don't. Prefer the recipient's
|
||||||
|
// own customerId over the order's, which isn't always populated.
|
||||||
|
const customer = invoice.primaryRecipient?.phoneNumber
|
||||||
|
? null
|
||||||
|
: await resolveCustomer(invoice.primaryRecipient?.customerId ?? order?.customerId);
|
||||||
|
const candidate = mapInvoiceToCandidate(invoice, order, customer);
|
||||||
|
if (candidate && (await upsertCandidate(candidate))) candidatesCreated++;
|
||||||
|
if (invoicesFetched >= MAX_ITEMS_PER_SOURCE) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentPage = await squareClient.payments.list({ locationId, beginTime, limit: 100 });
|
||||||
|
for await (const payment of paymentPage) {
|
||||||
|
paymentsFetched++;
|
||||||
|
const order = await resolveOrder(payment.orderId);
|
||||||
|
// Payments carry customerId directly — prefer that over the order's,
|
||||||
|
// which isn't always populated even when the payment's is.
|
||||||
|
const customer = await resolveCustomer(payment.customerId ?? order?.customerId);
|
||||||
|
const candidate = mapPaymentToCandidate(payment, order, customer);
|
||||||
|
if (candidate && (await upsertCandidate(candidate))) candidatesCreated++;
|
||||||
|
if (paymentsFetched >= MAX_ITEMS_PER_SOURCE) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.syncRun.update({
|
||||||
|
where: { id: run.id },
|
||||||
|
data: { finishedAt: new Date(), invoicesFetched, paymentsFetched, candidatesCreated, error },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: run.id, invoicesFetched, paymentsFetched, candidatesCreated, error };
|
||||||
|
}
|
||||||
82
server/src/users/routes.ts
Normal file
82
server/src/users/routes.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../db.js";
|
||||||
|
import { hashPassword } from "../auth/password.js";
|
||||||
|
import { requireAdmin, requireAuth, type AuthedRequest } from "../auth/middleware.js";
|
||||||
|
|
||||||
|
export const usersRouter = Router();
|
||||||
|
|
||||||
|
usersRouter.use(requireAuth, requireAdmin);
|
||||||
|
|
||||||
|
usersRouter.get("/", async (_req, res) => {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { id: true, email: true, name: true, role: true, active: true, createdAt: true },
|
||||||
|
});
|
||||||
|
res.json(users);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createUserSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
password: z.string().min(8),
|
||||||
|
role: z.enum(["ADMIN", "USER"]).default("USER"),
|
||||||
|
});
|
||||||
|
|
||||||
|
usersRouter.post("/", async (req: AuthedRequest, res) => {
|
||||||
|
const parsed = createUserSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Invalid input" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { email, name, password, role } = parsed.data;
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||||
|
if (existing) {
|
||||||
|
res.status(409).json({ error: "A user with that email already exists" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email: email.toLowerCase(), name, passwordHash, role, createdById: req.user!.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ id: user.id, email: user.email, name: user.name, role: user.role, active: user.active });
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserSchema = z.object({
|
||||||
|
role: z.enum(["ADMIN", "USER"]).optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
usersRouter.patch("/:id", async (req, res) => {
|
||||||
|
const parsed = updateUserSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Invalid input" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: parsed.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ id: user.id, email: user.email, name: user.name, role: user.role, active: user.active });
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetPasswordSchema = z.object({
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
usersRouter.post("/:id/reset-password", async (req, res) => {
|
||||||
|
const parsed = resetPasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Invalid input" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(parsed.data.password);
|
||||||
|
await prisma.user.update({ where: { id: req.params.id }, data: { passwordHash } });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
18
server/tsconfig.json
Normal file
18
server/tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
20
web/index.html
Normal file
20
web/index.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#fef6e4" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Autour+One&family=Quicksand:wght@500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>InflateHQ — Beach Party Balloons</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
web/package.json
Normal file
26
web/package.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router-dom": "^7.18.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/react": "^19.0.2",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^8.2.2",
|
||||||
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
web/public/favicon.svg
Normal file
12
web/public/favicon.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#bc13fe"/>
|
||||||
|
<stop offset="1" stop-color="#11c9f7"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
|
||||||
|
<ellipse cx="16" cy="13" rx="8" ry="9.5" fill="#ffd100" stroke="#fef6e4" stroke-width="1.2"/>
|
||||||
|
<polygon points="14.5,22 17.5,22 16,24.5" fill="#ffd100"/>
|
||||||
|
<path d="M16 24.5 C15 26 17.2 27 16 29" fill="none" stroke="#fef6e4" stroke-width="1" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 584 B |
BIN
web/public/pwa-192.png
Normal file
BIN
web/public/pwa-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
web/public/pwa-512.png
Normal file
BIN
web/public/pwa-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
70
web/src/App.tsx
Normal file
70
web/src/App.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
|
import { AuthProvider, useAuth } from "./auth/AuthContext";
|
||||||
|
import { ToastProvider } from "./components/Toast";
|
||||||
|
import { Layout } from "./components/Layout";
|
||||||
|
import { Login } from "./pages/Login";
|
||||||
|
import { PendingQueue } from "./pages/PendingQueue";
|
||||||
|
import { CalendarView } from "./pages/CalendarView";
|
||||||
|
import { CandidateDetail } from "./pages/CandidateDetail";
|
||||||
|
import { Approved } from "./pages/Approved";
|
||||||
|
import { Rejected } from "./pages/Rejected";
|
||||||
|
import { Account } from "./pages/Account";
|
||||||
|
import { Users } from "./pages/admin/Users";
|
||||||
|
|
||||||
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (loading) return <p className="p-4 text-sm text-ink-soft">Getting things ready…</p>;
|
||||||
|
if (!user) return <Navigate to="/login" state={{ from: location.pathname }} replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequireAdmin({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
if (user?.role !== "ADMIN") return <Navigate to="/" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<Layout />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<PendingQueue />} />
|
||||||
|
<Route path="calendar" element={<CalendarView />} />
|
||||||
|
<Route path="candidates/:id" element={<CandidateDetail />} />
|
||||||
|
<Route path="approved" element={<Approved />} />
|
||||||
|
<Route path="rejected" element={<Rejected />} />
|
||||||
|
<Route path="account" element={<Account />} />
|
||||||
|
<Route
|
||||||
|
path="admin/users"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<Users />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
web/src/api/client.ts
Normal file
37
web/src/api/client.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message: string, readonly status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`/api${path}`, {
|
||||||
|
...init,
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isJson = res.headers.get("content-type")?.includes("application/json");
|
||||||
|
const body = isJson ? await res.json() : undefined;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(body?.error ?? `Request failed (${res.status})`, res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => request<T>(path),
|
||||||
|
post: <T>(path: string, data?: unknown) => request<T>(path, { method: "POST", body: data ? JSON.stringify(data) : undefined }),
|
||||||
|
patch: <T>(path: string, data: unknown) => request<T>(path, { method: "PATCH", body: JSON.stringify(data) }),
|
||||||
|
};
|
||||||
68
web/src/api/resources.ts
Normal file
68
web/src/api/resources.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { api } from "./client";
|
||||||
|
import type { Candidate, CandidateList, CandidateStatus, CurrentUser, SourceType, SyncRun, User, Role } from "./types";
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (email: string, password: string) => api.post<CurrentUser>("/auth/login", { email, password }),
|
||||||
|
logout: () => api.post<void>("/auth/logout"),
|
||||||
|
me: () => api.get<CurrentUser>("/auth/me"),
|
||||||
|
updateMe: (data: { currentPassword: string; name?: string; email?: string; newPassword?: string }) =>
|
||||||
|
api.patch<CurrentUser>("/auth/me", data),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SortBy = "startAt" | "amountCents" | "updatedAt" | "customerName" | "squareCreatedAt";
|
||||||
|
export type SortDir = "asc" | "desc";
|
||||||
|
export interface SortOption {
|
||||||
|
sortBy: SortBy;
|
||||||
|
sortDir: SortDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListFilters {
|
||||||
|
status: CandidateStatus;
|
||||||
|
page?: number;
|
||||||
|
sort?: SortOption;
|
||||||
|
sourceType?: SourceType;
|
||||||
|
squareStatus?: string[];
|
||||||
|
dateFrom?: Date;
|
||||||
|
dateTo?: Date;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const candidatesApi = {
|
||||||
|
list: (filters: ListFilters) => {
|
||||||
|
const params = new URLSearchParams({ status: filters.status, page: String(filters.page ?? 1) });
|
||||||
|
if (filters.sort) {
|
||||||
|
params.set("sortBy", filters.sort.sortBy);
|
||||||
|
params.set("sortDir", filters.sort.sortDir);
|
||||||
|
}
|
||||||
|
if (filters.sourceType) params.set("sourceType", filters.sourceType);
|
||||||
|
if (filters.squareStatus?.length) params.set("squareStatus", filters.squareStatus.join(","));
|
||||||
|
if (filters.dateFrom) params.set("dateFrom", filters.dateFrom.toISOString());
|
||||||
|
if (filters.dateTo) params.set("dateTo", filters.dateTo.toISOString());
|
||||||
|
if (filters.search?.trim()) params.set("search", filters.search.trim());
|
||||||
|
return api.get<CandidateList>(`/candidates?${params.toString()}`);
|
||||||
|
},
|
||||||
|
get: (id: string) => api.get<Candidate>(`/candidates/${id}`),
|
||||||
|
listByDateRange: (from: Date, to: Date, statuses: CandidateStatus[] = ["PENDING", "APPROVED"], sourceType?: SourceType) =>
|
||||||
|
api.get<CandidateList>(
|
||||||
|
`/candidates/calendar?from=${from.toISOString()}&to=${to.toISOString()}&statuses=${statuses.join(",")}${
|
||||||
|
sourceType ? `&sourceType=${sourceType}` : ""
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
update: (id: string, data: Partial<Candidate>) => api.patch<Candidate>(`/candidates/${id}`, data),
|
||||||
|
approve: (id: string) => api.post<Candidate>(`/candidates/${id}/approve`),
|
||||||
|
markManual: (id: string) => api.post<Candidate>(`/candidates/${id}/mark-manual`),
|
||||||
|
reject: (id: string) => api.post<Candidate>(`/candidates/${id}/reject`),
|
||||||
|
unreject: (id: string) => api.post<Candidate>(`/candidates/${id}/unreject`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncApi = {
|
||||||
|
run: () => api.post<SyncRun>("/sync"),
|
||||||
|
last: () => api.get<SyncRun | null>("/sync/last"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
list: () => api.get<User[]>("/users"),
|
||||||
|
create: (data: { email: string; name: string; password: string; role: Role }) => api.post<User>("/users", data),
|
||||||
|
update: (id: string, data: { role?: Role; active?: boolean }) => api.patch<User>(`/users/${id}`, data),
|
||||||
|
resetPassword: (id: string, password: string) => api.post<void>(`/users/${id}/reset-password`, { password }),
|
||||||
|
};
|
||||||
65
web/src/api/types.ts
Normal file
65
web/src/api/types.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
export type Role = "ADMIN" | "USER";
|
||||||
|
export type CandidateStatus = "PENDING" | "APPROVED" | "REJECTED";
|
||||||
|
export type SourceType = "SQUARE_INVOICE" | "SQUARE_PAYMENT";
|
||||||
|
|
||||||
|
export interface CurrentUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: Role;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Candidate {
|
||||||
|
id: string;
|
||||||
|
sourceType: SourceType;
|
||||||
|
squareId: string;
|
||||||
|
status: CandidateStatus;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string;
|
||||||
|
allDay: boolean;
|
||||||
|
location: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
customerEmail: string | null;
|
||||||
|
amountCents: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
rawPayload: unknown;
|
||||||
|
fetchedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
approvedById: string | null;
|
||||||
|
approvedAt: string | null;
|
||||||
|
rejectedAt: string | null;
|
||||||
|
caldavEventUid: string | null;
|
||||||
|
hasStrike: boolean;
|
||||||
|
strikeStartAt: string | null;
|
||||||
|
strikeEndAt: string | null;
|
||||||
|
strikeCaldavEventUid: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateList {
|
||||||
|
items: Candidate[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncRun {
|
||||||
|
id: string;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
trigger: string;
|
||||||
|
invoicesFetched: number;
|
||||||
|
paymentsFetched: number;
|
||||||
|
candidatesCreated: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
44
web/src/auth/AuthContext.tsx
Normal file
44
web/src/auth/AuthContext.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { authApi } from "../api/resources";
|
||||||
|
import type { CurrentUser } from "../api/types";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: CurrentUser | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
setUser: (user: CurrentUser) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
authApi
|
||||||
|
.me()
|
||||||
|
.then(setUser)
|
||||||
|
.catch(() => setUser(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function login(email: string, password: string) {
|
||||||
|
const loggedInUser = await authApi.login(email, password);
|
||||||
|
setUser(loggedInUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await authApi.logout();
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={{ user, loading, login, logout, setUser }}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
78
web/src/components/BottomTabBar.tsx
Normal file
78
web/src/components/BottomTabBar.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ to: "/", label: "Review", icon: "🎈" },
|
||||||
|
{ to: "/calendar", label: "Calendar", icon: "📅" },
|
||||||
|
{ to: "/approved", label: "Approved", icon: "✅" },
|
||||||
|
{ to: "/rejected", label: "Rejected", icon: "🙈" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BottomTabBar() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const allTabs = user?.role === "ADMIN" ? [...tabs, { to: "/admin/users", label: "Team", icon: "⭐" }] : tabs;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="safe-bottom fixed bottom-0 left-0 right-0 z-20 border-t border-sand-deep bg-white/95 backdrop-blur md:hidden">
|
||||||
|
<div className="flex">
|
||||||
|
{allTabs.map((tab) => (
|
||||||
|
<NavLink
|
||||||
|
key={tab.to}
|
||||||
|
to={tab.to}
|
||||||
|
end={tab.to === "/"}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex flex-1 flex-col items-center gap-0.5 py-2.5 text-[11px] font-semibold ${
|
||||||
|
isActive ? "text-party-purple-dark" : "text-ink-soft"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`flex h-7 w-7 items-center justify-center rounded-full text-base leading-none ${
|
||||||
|
isActive ? "bg-gradient-to-br from-party-purple to-party-cyan" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
</span>
|
||||||
|
{tab.label}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SideNav() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const allTabs = user?.role === "ADMIN" ? [...tabs, { to: "/admin/users", label: "Team", icon: "⭐" }] : tabs;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="hidden w-52 shrink-0 border-r border-sand-deep bg-white/60 p-4 md:block">
|
||||||
|
<div className="mb-4 flex items-center gap-2 px-2">
|
||||||
|
<span className="text-2xl">🎈</span>
|
||||||
|
<span className="font-display text-sm leading-tight text-ink">Beach Party Balloons</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{allTabs.map((tab) => (
|
||||||
|
<NavLink
|
||||||
|
key={tab.to}
|
||||||
|
to={tab.to}
|
||||||
|
end={tab.to === "/"}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2 rounded-full px-3 py-2 text-sm font-semibold ${
|
||||||
|
isActive
|
||||||
|
? "bg-gradient-to-r from-party-purple to-party-cyan text-white shadow-sm"
|
||||||
|
: "text-ink-soft hover:bg-sand-deep/60"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{tab.icon}</span> {tab.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
web/src/components/CandidateCard.tsx
Normal file
58
web/src/components/CandidateCard.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import type { Candidate } from "../api/types";
|
||||||
|
|
||||||
|
function formatMoney(amountCents: number | null, currency: string | null): string | null {
|
||||||
|
if (amountCents === null) return null;
|
||||||
|
return new Intl.NumberFormat(undefined, { style: "currency", currency: currency ?? "USD" }).format(amountCents / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string, allDay: boolean): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return allDay
|
||||||
|
? date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })
|
||||||
|
: date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CandidateCard({ candidate, hideAmount }: { candidate: Candidate; hideAmount?: boolean }) {
|
||||||
|
const amount = hideAmount ? null : formatMoney(candidate.amountCents, candidate.currency);
|
||||||
|
const isInvoice = candidate.sourceType === "SQUARE_INVOICE";
|
||||||
|
const isApproved = candidate.status === "APPROVED";
|
||||||
|
const isOnCalendar = isApproved && Boolean(candidate.caldavEventUid);
|
||||||
|
const isManual = isApproved && !candidate.caldavEventUid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/candidates/${candidate.id}`}
|
||||||
|
className="block rounded-2xl border border-sand-deep bg-white p-4 shadow-sm transition-shadow active:shadow-none"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[11px] font-bold ${
|
||||||
|
isInvoice ? "bg-party-purple/15 text-party-purple-dark" : "bg-party-lime/25 text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isInvoice ? "🧾 Invoice" : "💳 Payment"}
|
||||||
|
</span>
|
||||||
|
{isOnCalendar && (
|
||||||
|
<span className="rounded-full bg-party-cyan/20 px-2 py-0.5 text-[11px] font-bold text-ink">📅 On calendar</span>
|
||||||
|
)}
|
||||||
|
{isManual && (
|
||||||
|
<span className="rounded-full bg-sand-deep px-2 py-0.5 text-[11px] font-bold text-ink-soft">✍️ Entered by hand</span>
|
||||||
|
)}
|
||||||
|
{candidate.hasStrike && (
|
||||||
|
<span className="rounded-full bg-party-gold/30 px-2 py-0.5 text-[11px] font-bold text-ink">💥 + Strike</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Amount is shown for transactions (that's the point of a payment record) but
|
||||||
|
omitted for invoices/events — the calendar entry is about the event, not the money. */}
|
||||||
|
{!isInvoice && amount && <span className="text-sm font-bold text-ink">{amount}</span>}
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-2 font-display text-base leading-tight text-ink">{candidate.title}</h3>
|
||||||
|
<p className="mt-1 text-sm text-ink-soft">
|
||||||
|
{formatDate(candidate.startAt, candidate.allDay)}
|
||||||
|
{candidate.location ? ` · ${candidate.location}` : ""}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
web/src/components/CandidateFilters.tsx
Normal file
105
web/src/components/CandidateFilters.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
from?: string; // yyyy-mm-dd
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVOICE_STATUSES = [
|
||||||
|
"UNPAID",
|
||||||
|
"SCHEDULED",
|
||||||
|
"PARTIALLY_PAID",
|
||||||
|
"PAID",
|
||||||
|
"PARTIALLY_REFUNDED",
|
||||||
|
"REFUNDED",
|
||||||
|
"CANCELED",
|
||||||
|
"FAILED",
|
||||||
|
"PAYMENT_PENDING",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CandidateFilters({
|
||||||
|
showStatusFilter,
|
||||||
|
dateRange,
|
||||||
|
onDateRangeChange,
|
||||||
|
statuses,
|
||||||
|
onStatusesChange,
|
||||||
|
}: {
|
||||||
|
showStatusFilter: boolean;
|
||||||
|
dateRange: DateRange;
|
||||||
|
onDateRangeChange: (r: DateRange) => void;
|
||||||
|
statuses: string[];
|
||||||
|
onStatusesChange: (s: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const activeCount = (dateRange.from ? 1 : 0) + (dateRange.to ? 1 : 0) + statuses.length;
|
||||||
|
|
||||||
|
function toggleStatus(s: string) {
|
||||||
|
onStatusesChange(statuses.includes(s) ? statuses.filter((x) => x !== s) : [...statuses, s]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 rounded-full border-2 border-ink-soft/70 bg-white px-2.5 py-1.5 text-xs font-semibold text-ink shadow-sm"
|
||||||
|
>
|
||||||
|
🔧 Filters{activeCount > 0 ? ` (${activeCount})` : ""}
|
||||||
|
<span className="text-ink-soft">{open ? "▲" : "▼"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 space-y-3 rounded-2xl border border-sand-deep bg-white p-3">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold text-ink-soft">Date range</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.from ?? ""}
|
||||||
|
onChange={(e) => onDateRangeChange({ ...dateRange, from: e.target.value || undefined })}
|
||||||
|
className="flex-1 rounded-lg border-2 border-ink-soft/70 bg-field px-2 py-1.5 text-xs text-ink outline-none focus:border-party-purple"
|
||||||
|
/>
|
||||||
|
<span className="text-ink-soft">to</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.to ?? ""}
|
||||||
|
onChange={(e) => onDateRangeChange({ ...dateRange, to: e.target.value || undefined })}
|
||||||
|
className="flex-1 rounded-lg border-2 border-ink-soft/70 bg-field px-2 py-1.5 text-xs text-ink outline-none focus:border-party-purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showStatusFilter && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold text-ink-soft">Invoice status</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{INVOICE_STATUSES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => toggleStatus(s)}
|
||||||
|
className={`rounded-full border px-2 py-1 text-[11px] font-semibold ${
|
||||||
|
statuses.includes(s) ? "border-party-purple bg-party-purple/15 text-party-purple-dark" : "border-ink-soft/50 text-ink-soft"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.replaceAll("_", " ")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onDateRangeChange({});
|
||||||
|
onStatusesChange([]);
|
||||||
|
}}
|
||||||
|
className="text-xs font-semibold text-ink-soft underline"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
web/src/components/Layout.tsx
Normal file
20
web/src/components/Layout.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { TopBar } from "./TopBar";
|
||||||
|
import { BottomTabBar, SideNav } from "./BottomTabBar";
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-col md:flex-row">
|
||||||
|
<SideNav />
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<TopBar />
|
||||||
|
<main className="flex-1 pb-20 md:pb-6">
|
||||||
|
<div className="mx-auto max-w-2xl p-4">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<BottomTabBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
web/src/components/SearchBox.tsx
Normal file
41
web/src/components/SearchBox.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Debounces typing before calling onSearch, so we don't fire a request per keystroke. */
|
||||||
|
export function SearchBox({ value, onSearch }: { value: string; onSearch: (q: string) => void }) {
|
||||||
|
const [text, setText] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => setText(value), [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (text !== value) onSearch(text);
|
||||||
|
}, 350);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-soft">🔍</span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
inputMode="search"
|
||||||
|
placeholder="Search customer, date, location…"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
className="w-full rounded-full border-2 border-ink-soft/70 bg-white py-2.5 pl-9 pr-8 text-sm text-ink outline-none shadow-sm placeholder:text-ink-soft/70 focus:border-party-purple"
|
||||||
|
/>
|
||||||
|
{text && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setText("");
|
||||||
|
onSearch("");
|
||||||
|
}}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-ink-soft"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
web/src/components/SortPicker.tsx
Normal file
37
web/src/components/SortPicker.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { SortOption } from "../api/resources";
|
||||||
|
|
||||||
|
export const SORT_OPTIONS: { label: string; value: SortOption }[] = [
|
||||||
|
{ label: "Created: newest", value: { sortBy: "squareCreatedAt", sortDir: "desc" } },
|
||||||
|
{ label: "Created: oldest", value: { sortBy: "squareCreatedAt", sortDir: "asc" } },
|
||||||
|
{ label: "Event date: newest", value: { sortBy: "startAt", sortDir: "desc" } },
|
||||||
|
{ label: "Event date: oldest", value: { sortBy: "startAt", sortDir: "asc" } },
|
||||||
|
{ label: "Customer name: A-Z", value: { sortBy: "customerName", sortDir: "asc" } },
|
||||||
|
{ label: "Customer name: Z-A", value: { sortBy: "customerName", sortDir: "desc" } },
|
||||||
|
{ label: "Amount: high to low", value: { sortBy: "amountCents", sortDir: "desc" } },
|
||||||
|
{ label: "Amount: low to high", value: { sortBy: "amountCents", sortDir: "asc" } },
|
||||||
|
{ label: "Recently updated", value: { sortBy: "updatedAt", sortDir: "desc" } },
|
||||||
|
{ label: "Oldest updated", value: { sortBy: "updatedAt", sortDir: "asc" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function optionKey(sort: SortOption): string {
|
||||||
|
return `${sort.sortBy}:${sort.sortDir}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortPicker({ value, onChange }: { value: SortOption; onChange: (v: SortOption) => void }) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={optionKey(value)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const found = SORT_OPTIONS.find((o) => optionKey(o.value) === e.target.value);
|
||||||
|
if (found) onChange(found.value);
|
||||||
|
}}
|
||||||
|
className="rounded-full border-2 border-ink-soft/70 bg-white px-2.5 py-1.5 text-xs font-semibold text-ink outline-none shadow-sm focus:border-party-purple"
|
||||||
|
>
|
||||||
|
{SORT_OPTIONS.map((o) => (
|
||||||
|
<option key={optionKey(o.value)} value={optionKey(o.value)}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
web/src/components/Toast.tsx
Normal file
51
web/src/components/Toast.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type ToastKind = "success" | "error" | "info";
|
||||||
|
interface ToastItem {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
kind: ToastKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<((message: string, kind?: ToastKind) => void) | undefined>(undefined);
|
||||||
|
|
||||||
|
const ICONS: Record<ToastKind, string> = { success: "🎉", error: "😬", info: "🎈" };
|
||||||
|
const STYLES: Record<ToastKind, string> = {
|
||||||
|
success: "bg-party-lime/90 text-ink border-party-lime",
|
||||||
|
error: "bg-party-coral text-white border-party-coral",
|
||||||
|
info: "bg-white text-ink border-sand-deep",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
const nextId = useRef(0);
|
||||||
|
|
||||||
|
const showToast = useCallback((message: string, kind: ToastKind = "success") => {
|
||||||
|
const id = nextId.current++;
|
||||||
|
setToasts((prev) => [...prev, { id, message, kind }]);
|
||||||
|
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3200);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={showToast}>
|
||||||
|
{children}
|
||||||
|
<div className="safe-bottom pointer-events-none fixed inset-x-0 bottom-20 z-50 flex flex-col items-center gap-2 px-4 md:bottom-6">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`pointer-events-auto flex max-w-sm items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold shadow-lg ${STYLES[t.kind]}`}
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">{ICONS[t.kind]}</span>
|
||||||
|
{t.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error("useToast must be used within ToastProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
75
web/src/components/TopBar.tsx
Normal file
75
web/src/components/TopBar.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { syncApi } from "../api/resources";
|
||||||
|
import type { SyncRun } from "../api/types";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
|
function timeAgo(iso: string | null): string {
|
||||||
|
if (!iso) return "never";
|
||||||
|
const diffMs = Date.now() - new Date(iso).getTime();
|
||||||
|
const mins = Math.round(diffMs / 60000);
|
||||||
|
if (mins < 1) return "just now";
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.round(mins / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const showToast = useToast();
|
||||||
|
const [lastSync, setLastSync] = useState<SyncRun | null>(null);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
|
||||||
|
function refreshLastSync() {
|
||||||
|
syncApi.last().then(setLastSync).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(refreshLastSync, []);
|
||||||
|
|
||||||
|
async function handleSync() {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const result = await syncApi.run();
|
||||||
|
setLastSync(result);
|
||||||
|
if (result.error) {
|
||||||
|
showToast("Sync hit a snag — check again in a bit.", "error");
|
||||||
|
} else if (result.candidatesCreated > 0) {
|
||||||
|
showToast(`Found ${result.candidatesCreated} new ${result.candidatesCreated === 1 ? "order" : "orders"}!`, "success");
|
||||||
|
} else {
|
||||||
|
showToast("All caught up — nothing new.", "info");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast("Couldn't reach Square. Try again?", "error");
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="safe-top sticky top-0 z-20 flex items-center justify-between gap-3 border-b border-sand-deep bg-white/95 px-4 py-3 backdrop-blur">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl md:hidden">🎈</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-sm leading-tight text-ink">InflateHQ</h1>
|
||||||
|
<p className="text-xs text-ink-soft">Last synced {timeAgo(lastSync?.finishedAt ?? null)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSync}
|
||||||
|
disabled={syncing}
|
||||||
|
className="rounded-full bg-gradient-to-r from-party-purple to-party-cyan px-3.5 py-1.5 text-xs font-bold text-white shadow-sm disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{syncing ? "Syncing…" : "🔄 Sync now"}
|
||||||
|
</button>
|
||||||
|
<Link to="/account" className="hidden text-xs font-semibold text-ink-soft underline decoration-dotted sm:inline" title={user?.email}>
|
||||||
|
{user?.name}
|
||||||
|
</Link>
|
||||||
|
<button onClick={() => logout()} className="text-xs font-semibold text-ink-soft hover:text-party-coral">
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
web/src/hooks/usePersistedState.ts
Normal file
24
web/src/hooks/usePersistedState.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/** Like useState, but persisted to localStorage under `key` so it survives reloads/sessions. */
|
||||||
|
export function usePersistedState<T>(key: string, defaultValue: T) {
|
||||||
|
const [value, setValue] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(key);
|
||||||
|
return stored ? (JSON.parse(stored) as T) : defaultValue;
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function update(next: T) {
|
||||||
|
setValue(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// storage unavailable (private browsing, quota, etc.) — fine to skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [value, update] as const;
|
||||||
|
}
|
||||||
55
web/src/index.css
Normal file
55
web/src/index.css
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-sand: #fef6e4;
|
||||||
|
--color-sand-deep: #f2e9d0;
|
||||||
|
--color-field: #ece0fa;
|
||||||
|
--color-ink: #15384c;
|
||||||
|
--color-ink-soft: #4d6a7d;
|
||||||
|
--color-party-purple: #bc13fe;
|
||||||
|
--color-party-purple-dark: #9a0fd1;
|
||||||
|
--color-party-cyan: #11c9f7;
|
||||||
|
--color-party-gold: #ffd100;
|
||||||
|
--color-party-lime: #94d601;
|
||||||
|
--color-party-coral: #ff3860;
|
||||||
|
|
||||||
|
--font-display: "Autour One", "Quicksand", sans-serif;
|
||||||
|
--font-body: "Quicksand", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-sand);
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
.font-display {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-top {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A little life on tap, without fighting Tailwind's utility classes everywhere. */
|
||||||
|
button,
|
||||||
|
a {
|
||||||
|
transition: transform 0.1s ease, opacity 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
113
web/src/pages/Account.tsx
Normal file
113
web/src/pages/Account.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { authApi } from "../api/resources";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { useToast } from "../components/Toast";
|
||||||
|
|
||||||
|
export function Account() {
|
||||||
|
const { user, setUser } = useAuth();
|
||||||
|
const showToast = useToast();
|
||||||
|
const [name, setName] = useState(user?.name ?? "");
|
||||||
|
const [email, setEmail] = useState(user?.email ?? "");
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"mt-1 w-full rounded-xl border-2 border-ink-soft/70 bg-field px-3 py-2.5 text-ink outline-none focus:border-party-purple";
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (newPassword && newPassword !== confirmPassword) {
|
||||||
|
setError("New passwords don't match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const updated = await authApi.updateMe({
|
||||||
|
currentPassword,
|
||||||
|
name: name !== user?.name ? name : undefined,
|
||||||
|
email: email !== user?.email ? email : undefined,
|
||||||
|
newPassword: newPassword || undefined,
|
||||||
|
});
|
||||||
|
setUser(updated);
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
showToast("Account updated! 🎈", "success");
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : "Failed to update account";
|
||||||
|
setError(message);
|
||||||
|
showToast(message, "error");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 font-display text-lg text-ink">My account 🙋</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="rounded-2xl border border-sand-deep bg-white p-4 shadow-sm">
|
||||||
|
<label className="block text-sm font-semibold text-ink">
|
||||||
|
Name
|
||||||
|
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-sm font-semibold text-ink">
|
||||||
|
Email
|
||||||
|
<input type="email" className={inputClass} value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-sm font-semibold text-ink">
|
||||||
|
New password <span className="font-normal text-ink-soft">(leave blank to keep current)</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
minLength={8}
|
||||||
|
className={inputClass}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{newPassword && (
|
||||||
|
<label className="mt-3 block text-sm font-semibold text-ink">
|
||||||
|
Confirm new password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={inputClass}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="mt-4 block text-sm font-semibold text-ink">
|
||||||
|
Current password <span className="font-normal text-ink-soft">(required to save any change)</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 text-sm font-semibold text-party-coral">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="mt-4 w-full rounded-full bg-gradient-to-r from-party-purple to-party-cyan py-2.5 font-bold text-white shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? "Saving…" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web/src/pages/Approved.tsx
Normal file
10
web/src/pages/Approved.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { HistoryList } from "./HistoryList";
|
||||||
|
|
||||||
|
export function Approved() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 font-display text-lg text-ink">On the calendar ✅</h2>
|
||||||
|
<HistoryList status="APPROVED" emptyMessage="Nothing approved yet — get reviewing! 🎈" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
web/src/pages/CalendarView.tsx
Normal file
205
web/src/pages/CalendarView.tsx
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { candidatesApi } from "../api/resources";
|
||||||
|
import type { Candidate, CandidateStatus } from "../api/types";
|
||||||
|
import { CandidateCard } from "../components/CandidateCard";
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { status: CandidateStatus; label: string; dotClass: string }[] = [
|
||||||
|
{ status: "PENDING", label: "Pending", dotClass: "bg-party-gold" },
|
||||||
|
{ status: "APPROVED", label: "Approved", dotClass: "bg-party-cyan" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function startOfMonth(d: Date): Date {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(d: Date, n: number): Date {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth() + n, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Sunday on or before the 1st of the month, so the grid always starts on a Sunday. */
|
||||||
|
function gridStart(monthStart: Date): Date {
|
||||||
|
const d = new Date(monthStart);
|
||||||
|
d.setDate(d.getDate() - d.getDay());
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateKey(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDay(a: Date, b: Date): boolean {
|
||||||
|
return dateKey(a) === dateKey(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarView() {
|
||||||
|
const [cursor, setCursor] = useState(() => startOfMonth(new Date()));
|
||||||
|
const [selected, setSelected] = useState(() => new Date());
|
||||||
|
const [items, setItems] = useState<Candidate[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [statuses, setStatuses] = useState<Set<CandidateStatus>>(new Set(["PENDING", "APPROVED"]));
|
||||||
|
|
||||||
|
const gridDays = useMemo(() => {
|
||||||
|
const start = gridStart(cursor);
|
||||||
|
return Array.from({ length: 42 }, (_, i) => {
|
||||||
|
const d = new Date(start);
|
||||||
|
d.setDate(d.getDate() + i);
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
}, [cursor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const from = gridDays[0];
|
||||||
|
const to = new Date(gridDays[41]);
|
||||||
|
to.setDate(to.getDate() + 1);
|
||||||
|
const activeStatuses = Array.from(statuses);
|
||||||
|
if (activeStatuses.length === 0) {
|
||||||
|
setItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
candidatesApi
|
||||||
|
.listByDateRange(from, to, activeStatuses)
|
||||||
|
.then((res) => setItems(res.items))
|
||||||
|
.catch(() => setError("Couldn't load calendar items."));
|
||||||
|
}, [gridDays, statuses]);
|
||||||
|
|
||||||
|
const itemsByDay = useMemo(() => {
|
||||||
|
const map = new Map<string, Candidate[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = dateKey(new Date(item.startAt));
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
function toggleStatus(status: CandidateStatus) {
|
||||||
|
setStatuses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(status)) next.delete(status);
|
||||||
|
else next.add(status);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItems = itemsByDay.get(dateKey(selected)) ?? [];
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="font-display text-lg text-ink">
|
||||||
|
{cursor.toLocaleDateString(undefined, { month: "long", year: "numeric" })}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCursor((c) => addMonths(c, -1))}
|
||||||
|
className="rounded-full border border-sand-deep bg-white px-2.5 py-1 text-sm font-bold text-ink"
|
||||||
|
aria-label="Previous month"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCursor(startOfMonth(new Date()));
|
||||||
|
setSelected(new Date());
|
||||||
|
}}
|
||||||
|
className="rounded-full border border-sand-deep bg-white px-2.5 py-1 text-xs font-bold text-ink"
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCursor((c) => addMonths(c, 1))}
|
||||||
|
className="rounded-full border border-sand-deep bg-white px-2.5 py-1 text-sm font-bold text-ink"
|
||||||
|
aria-label="Next month"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.status}
|
||||||
|
onClick={() => toggleStatus(f.status)}
|
||||||
|
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-bold ${
|
||||||
|
statuses.has(f.status) ? "border-party-purple/40 bg-white text-ink" : "border-sand-deep text-ink-soft"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${f.dotClass}`} />
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mb-2 text-sm font-semibold text-party-coral">{error}</p>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center text-[11px] font-bold text-ink-soft">
|
||||||
|
{WEEKDAY_LABELS.map((w, i) => (
|
||||||
|
<div key={i} className="pb-1">
|
||||||
|
{w}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{gridDays.map((day) => {
|
||||||
|
const inMonth = day.getMonth() === cursor.getMonth();
|
||||||
|
const dayItems = itemsByDay.get(dateKey(day)) ?? [];
|
||||||
|
const isSelected = isSameDay(day, selected);
|
||||||
|
const isToday = isSameDay(day, today);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dateKey(day)}
|
||||||
|
onClick={() => setSelected(day)}
|
||||||
|
className={`flex aspect-square flex-col items-center justify-center gap-0.5 rounded-xl text-sm font-semibold ${
|
||||||
|
isSelected
|
||||||
|
? "bg-gradient-to-br from-party-purple to-party-cyan text-white"
|
||||||
|
: inMonth
|
||||||
|
? "bg-white text-ink"
|
||||||
|
: "text-ink-soft/50"
|
||||||
|
} ${!isSelected && isToday ? "ring-2 ring-party-gold" : ""}`}
|
||||||
|
>
|
||||||
|
<span>{day.getDate()}</span>
|
||||||
|
{dayItems.length > 0 && (
|
||||||
|
<span className="flex gap-0.5">
|
||||||
|
{dayItems.slice(0, 3).map((item, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`h-1 w-1 rounded-full ${
|
||||||
|
isSelected
|
||||||
|
? "bg-white"
|
||||||
|
: item.status === "APPROVED"
|
||||||
|
? "bg-party-cyan"
|
||||||
|
: "bg-party-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<h3 className="mb-2 font-display text-sm text-ink">
|
||||||
|
{selected.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })}
|
||||||
|
</h3>
|
||||||
|
{selectedItems.length === 0 ? (
|
||||||
|
<p className="rounded-2xl border-2 border-dashed border-sand-deep bg-white/60 p-4 text-center text-sm text-ink-soft">
|
||||||
|
Nothing scheduled — enjoy the quiet! 🏖️
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{selectedItems.map((item) => (
|
||||||
|
<CandidateCard key={item.id} candidate={item} hideAmount />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
389
web/src/pages/CandidateDetail.tsx
Normal file
389
web/src/pages/CandidateDetail.tsx
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { candidatesApi } from "../api/resources";
|
||||||
|
import type { Candidate } from "../api/types";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
import { useToast } from "../components/Toast";
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
location: string;
|
||||||
|
customerName: string;
|
||||||
|
allDay: boolean;
|
||||||
|
startAt: string; // datetime-local or date value
|
||||||
|
endAt: string;
|
||||||
|
hasStrike: boolean;
|
||||||
|
strikeStartAt: string; // datetime-local
|
||||||
|
strikeEndAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number) {
|
||||||
|
return String(n).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDatetimeLocal(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateOnly(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** datetime-local value, one hour after the given datetime-local value. */
|
||||||
|
function oneHourLater(datetimeLocal: string): string {
|
||||||
|
const d = new Date(datetimeLocal);
|
||||||
|
if (isNaN(d.getTime())) return datetimeLocal;
|
||||||
|
d.setHours(d.getHours() + 1);
|
||||||
|
return toDatetimeLocal(d.toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultStrikeStart(startAt: string, allDay: boolean): string {
|
||||||
|
const base = allDay ? `${startAt}T09:00` : startAt;
|
||||||
|
const d = new Date(base);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
return toDatetimeLocal(d.toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function formToCandidate(form: FormState) {
|
||||||
|
const startAt = form.allDay ? new Date(`${form.startAt}T00:00:00`) : new Date(form.startAt);
|
||||||
|
const endAt = form.allDay ? new Date(`${form.endAt}T00:00:00`) : new Date(form.endAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description || null,
|
||||||
|
location: form.location || null,
|
||||||
|
customerName: form.customerName || null,
|
||||||
|
allDay: form.allDay,
|
||||||
|
startAt: startAt.toISOString(),
|
||||||
|
endAt: endAt.toISOString(),
|
||||||
|
hasStrike: form.hasStrike,
|
||||||
|
strikeStartAt: form.hasStrike && form.strikeStartAt ? new Date(form.strikeStartAt).toISOString() : null,
|
||||||
|
strikeEndAt: form.hasStrike && form.strikeEndAt ? new Date(form.strikeEndAt).toISOString() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateToForm(c: Candidate): FormState {
|
||||||
|
const startAt = c.allDay ? toDateOnly(c.startAt) : toDatetimeLocal(c.startAt);
|
||||||
|
return {
|
||||||
|
title: c.title,
|
||||||
|
description: c.description ?? "",
|
||||||
|
location: c.location ?? "",
|
||||||
|
customerName: c.customerName ?? "",
|
||||||
|
allDay: c.allDay,
|
||||||
|
startAt,
|
||||||
|
endAt: c.allDay ? toDateOnly(c.endAt) : toDatetimeLocal(c.endAt),
|
||||||
|
hasStrike: c.hasStrike,
|
||||||
|
strikeStartAt: c.strikeStartAt ? toDatetimeLocal(c.strikeStartAt) : defaultStrikeStart(startAt, c.allDay),
|
||||||
|
strikeEndAt: c.strikeEndAt ? toDatetimeLocal(c.strikeEndAt) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CandidateDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const showToast = useToast();
|
||||||
|
const [candidate, setCandidate] = useState<Candidate | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState<"save" | "approve" | "reject" | "unreject" | "manual" | null>(null);
|
||||||
|
const [showRaw, setShowRaw] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
candidatesApi
|
||||||
|
.get(id)
|
||||||
|
.then((c) => {
|
||||||
|
setCandidate(c);
|
||||||
|
setForm(candidateToForm(c));
|
||||||
|
})
|
||||||
|
.catch(() => setError("Couldn't load this item."));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!candidate || !form) {
|
||||||
|
return <p className="text-sm text-ink-soft">{error ?? "Loading…"}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||||
|
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing/changing a start time auto-fills the matching end time to one
|
||||||
|
// hour later (same day) — the end field stays independently editable
|
||||||
|
// after that.
|
||||||
|
function updateStart(startField: "startAt" | "strikeStartAt", endField: "endAt" | "strikeEndAt", value: string) {
|
||||||
|
setForm((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const allDayMain = startField === "startAt" && prev.allDay;
|
||||||
|
const end = allDayMain ? value : oneHourLater(value);
|
||||||
|
return { ...prev, [startField]: value, [endField]: end };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!id || !form) return;
|
||||||
|
setBusy("save");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await candidatesApi.update(id, formToCandidate(form));
|
||||||
|
setCandidate(updated);
|
||||||
|
setForm(candidateToForm(updated));
|
||||||
|
showToast("Changes saved! ✨", "success");
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : "Failed to save changes";
|
||||||
|
setError(message);
|
||||||
|
showToast(message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApprove() {
|
||||||
|
if (!id || !form) return;
|
||||||
|
setBusy("approve");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await candidatesApi.update(id, formToCandidate(form));
|
||||||
|
await candidatesApi.approve(id);
|
||||||
|
showToast("On the calendar! 🎉", "success");
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : "Failed to approve";
|
||||||
|
setError(message);
|
||||||
|
showToast(message, "error");
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkManual() {
|
||||||
|
if (!id || !form) return;
|
||||||
|
setBusy("manual");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await candidatesApi.update(id, formToCandidate(form));
|
||||||
|
await candidatesApi.markManual(id);
|
||||||
|
showToast("Got it — marked as entered.", "success");
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : "Failed to mark as entered";
|
||||||
|
setError(message);
|
||||||
|
showToast(message, "error");
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject() {
|
||||||
|
if (!id) return;
|
||||||
|
setBusy("reject");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await candidatesApi.reject(id);
|
||||||
|
showToast("Rejected.", "info");
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : "Failed to reject";
|
||||||
|
setError(message);
|
||||||
|
showToast(message, "error");
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnreject() {
|
||||||
|
if (!id) return;
|
||||||
|
setBusy("unreject");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await candidatesApi.unreject(id);
|
||||||
|
showToast("Back to Pending.", "success");
|
||||||
|
navigate("/rejected");
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : "Failed to unreject";
|
||||||
|
setError(message);
|
||||||
|
showToast(message, "error");
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"mt-1 w-full rounded-xl border-2 border-ink-soft/70 bg-field px-3 py-2.5 text-ink outline-none focus:border-party-purple";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-24">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="mb-3 flex items-center gap-1 text-sm font-semibold text-ink-soft"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-bold text-ink-soft shadow-sm">
|
||||||
|
{candidate.sourceType === "SQUARE_INVOICE" ? "🧾 Invoice" : "💳 Payment"} · {candidate.status}
|
||||||
|
</span>
|
||||||
|
{candidate.status === "APPROVED" && candidate.caldavEventUid && (
|
||||||
|
<span className="rounded-full bg-party-cyan/20 px-2 py-0.5 text-[11px] font-bold text-ink">📅 On calendar</span>
|
||||||
|
)}
|
||||||
|
{candidate.status === "APPROVED" && !candidate.caldavEventUid && (
|
||||||
|
<span className="rounded-full bg-sand-deep px-2 py-0.5 text-[11px] font-bold text-ink-soft">
|
||||||
|
✍️ Entered by hand
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{candidate.hasStrike && (
|
||||||
|
<span className="rounded-full bg-party-gold/30 px-2 py-0.5 text-[11px] font-bold text-ink">💥 + Strike</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block text-sm font-semibold text-ink">
|
||||||
|
Title
|
||||||
|
<input className={inputClass} value={form.title} onChange={(e) => update("title", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mt-3 flex items-center gap-2 text-sm font-semibold text-ink">
|
||||||
|
<input type="checkbox" checked={form.allDay} onChange={(e) => update("allDay", e.target.checked)} />
|
||||||
|
All-day event
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm font-semibold text-ink">
|
||||||
|
Start
|
||||||
|
<input
|
||||||
|
type={form.allDay ? "date" : "datetime-local"}
|
||||||
|
className={inputClass}
|
||||||
|
value={form.startAt}
|
||||||
|
onChange={(e) => updateStart("startAt", "endAt", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm font-semibold text-ink">
|
||||||
|
End
|
||||||
|
<input
|
||||||
|
type={form.allDay ? "date" : "datetime-local"}
|
||||||
|
className={inputClass}
|
||||||
|
value={form.endAt}
|
||||||
|
onChange={(e) => update("endAt", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-sm font-semibold text-ink">
|
||||||
|
Description
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
className={inputClass}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => update("description", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-sm font-semibold text-ink">
|
||||||
|
Location
|
||||||
|
<input className={inputClass} value={form.location} onChange={(e) => update("location", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-sm font-semibold text-ink">
|
||||||
|
Customer name
|
||||||
|
<input className={inputClass} value={form.customerName} onChange={(e) => update("customerName", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-2xl border border-sand-deep bg-white p-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-bold text-ink">
|
||||||
|
<input type="checkbox" checked={form.hasStrike} onChange={(e) => update("hasStrike", e.target.checked)} />
|
||||||
|
💥 Add a Strike (separate calendar entry for teardown/pickup)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{form.hasStrike && (
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm font-semibold text-ink">
|
||||||
|
Strike start
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className={inputClass}
|
||||||
|
value={form.strikeStartAt}
|
||||||
|
onChange={(e) => updateStart("strikeStartAt", "strikeEndAt", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm font-semibold text-ink">
|
||||||
|
Strike end
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className={inputClass}
|
||||||
|
value={form.strikeEndAt}
|
||||||
|
onChange={(e) => update("strikeEndAt", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="mt-4" open={showRaw} onToggle={(e) => setShowRaw((e.target as HTMLDetailsElement).open)}>
|
||||||
|
<summary className="cursor-pointer text-xs font-semibold text-ink-soft">Raw Square data</summary>
|
||||||
|
<pre className="mt-2 max-h-64 overflow-auto rounded-xl bg-ink p-3 text-[11px] text-sand">
|
||||||
|
{JSON.stringify(candidate.rawPayload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{candidate.status === "PENDING" && (
|
||||||
|
<button
|
||||||
|
onClick={handleMarkManual}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="mt-3 text-xs font-semibold text-ink-soft underline decoration-dotted disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy === "manual" ? "Marking…" : "Already on your calendar? Mark as entered (no calendar event created)"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="mt-3 text-sm font-semibold text-party-coral">{error}</p>}
|
||||||
|
|
||||||
|
<div className="safe-bottom fixed bottom-16 left-0 right-0 z-20 border-t border-sand-deep bg-white/95 p-3 backdrop-blur md:bottom-0 md:left-52">
|
||||||
|
<div className="mx-auto flex max-w-2xl gap-2">
|
||||||
|
{candidate.status === "PENDING" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="flex-1 rounded-full border-2 border-party-coral/40 py-3 font-bold text-party-coral disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy === "reject" ? "Rejecting…" : "✖️ Reject"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="flex-1 rounded-full bg-gradient-to-r from-party-purple to-party-cyan py-3 font-bold text-white shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy === "approve" ? "Approving…" : "🎈 Approve"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{candidate.status === "APPROVED" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="flex-1 rounded-full border-2 border-party-coral/40 py-3 font-bold text-party-coral disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy === "reject" ? "Removing…" : "✖️ Reject & remove"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="flex-1 rounded-full bg-gradient-to-r from-party-purple to-party-cyan py-3 font-bold text-white shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy === "save" ? "Saving…" : "💾 Save changes"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{candidate.status === "REJECTED" && (
|
||||||
|
<button
|
||||||
|
onClick={handleUnreject}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="flex-1 rounded-full bg-gradient-to-r from-party-purple to-party-cyan py-3 font-bold text-white shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy === "unreject" ? "Restoring…" : "↩️ Unreject (back to Pending)"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
web/src/pages/HistoryList.tsx
Normal file
73
web/src/pages/HistoryList.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { candidatesApi, type SortOption } from "../api/resources";
|
||||||
|
import type { Candidate, CandidateStatus } from "../api/types";
|
||||||
|
import { CandidateCard } from "../components/CandidateCard";
|
||||||
|
import { SortPicker } from "../components/SortPicker";
|
||||||
|
import { SearchBox } from "../components/SearchBox";
|
||||||
|
import { usePersistedState } from "../hooks/usePersistedState";
|
||||||
|
|
||||||
|
export function HistoryList({ status, emptyMessage }: { status: CandidateStatus; emptyMessage: string }) {
|
||||||
|
const [items, setItems] = useState<Candidate[] | null>(null);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sort, setSort] = usePersistedState<SortOption>(`history-sort-${status}`, { sortBy: "updatedAt", sortDir: "desc" });
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItems(null);
|
||||||
|
setPage(1);
|
||||||
|
candidatesApi
|
||||||
|
.list({ status, page: 1, sort, search })
|
||||||
|
.then((res) => {
|
||||||
|
setItems(res.items);
|
||||||
|
setTotal(res.total);
|
||||||
|
})
|
||||||
|
.catch(() => setError("Couldn't load items."));
|
||||||
|
}, [status, sort, search]);
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
const res = await candidatesApi.list({ status, page: nextPage, sort, search });
|
||||||
|
setItems((prev) => [...(prev ?? []), ...res.items]);
|
||||||
|
setTotal(res.total);
|
||||||
|
setPage(nextPage);
|
||||||
|
} catch {
|
||||||
|
setError("Couldn't load more items.");
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex justify-end">
|
||||||
|
<SortPicker value={sort} onChange={setSort} />
|
||||||
|
</div>
|
||||||
|
<SearchBox value={search} onSearch={setSearch} />
|
||||||
|
{error && <p className="text-sm font-semibold text-party-coral">{error}</p>}
|
||||||
|
{items === null && !error && <p className="text-sm text-ink-soft">Loading…</p>}
|
||||||
|
{items?.length === 0 && (
|
||||||
|
<p className="rounded-2xl border-2 border-dashed border-sand-deep bg-white/60 p-6 text-center text-sm text-ink-soft">
|
||||||
|
{search ? "No matches — try a different search. 🔍" : emptyMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items?.map((item) => <CandidateCard key={item.id} candidate={item} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items !== null && items.length < total && (
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="mt-4 w-full rounded-full border-2 border-party-purple/30 bg-white py-2.5 text-sm font-bold text-party-purple-dark disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loadingMore ? "Loading…" : `Load more (${items.length} of ${total})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
web/src/pages/Login.tsx
Normal file
72
web/src/pages/Login.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
const redirectTo = (location.state as { from?: string })?.from ?? "/";
|
||||||
|
navigate(redirectTo, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "That didn't work — check your email and password.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full items-center justify-center bg-gradient-to-br from-sand via-sand to-party-cyan/10 p-4">
|
||||||
|
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-3xl border border-sand-deep bg-white p-6 shadow-lg shadow-party-purple/5">
|
||||||
|
<div className="mb-1 text-4xl">🎈</div>
|
||||||
|
<h1 className="font-display text-xl leading-tight text-ink">InflateHQ</h1>
|
||||||
|
<p className="mt-1 text-sm text-ink-soft">Beach Party Balloons — sign in to review orders and get them on the calendar.</p>
|
||||||
|
|
||||||
|
<label className="mt-5 block text-sm font-semibold text-ink">
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-xl border-2 border-ink-soft/70 bg-field px-3 py-2.5 text-ink outline-none focus:border-party-purple"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-sm font-semibold text-ink">
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-xl border-2 border-ink-soft/70 bg-field px-3 py-2.5 text-ink outline-none focus:border-party-purple"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 text-sm font-semibold text-party-coral">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="mt-5 w-full rounded-full bg-gradient-to-r from-party-purple to-party-cyan py-2.5 font-bold text-white shadow-sm disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{submitting ? "Signing in…" : "Let's go! 🎉"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
web/src/pages/PendingQueue.tsx
Normal file
135
web/src/pages/PendingQueue.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { candidatesApi, type SortOption } from "../api/resources";
|
||||||
|
import type { Candidate, SourceType } from "../api/types";
|
||||||
|
import { CandidateCard } from "../components/CandidateCard";
|
||||||
|
import { SortPicker } from "../components/SortPicker";
|
||||||
|
import { CandidateFilters, type DateRange } from "../components/CandidateFilters";
|
||||||
|
import { SearchBox } from "../components/SearchBox";
|
||||||
|
import { usePersistedState } from "../hooks/usePersistedState";
|
||||||
|
|
||||||
|
const TABS: { sourceType: SourceType; label: string; emptyMessage: string }[] = [
|
||||||
|
{ sourceType: "SQUARE_INVOICE", label: "Invoices", emptyMessage: "No invoices waiting for review — nice work! 🎉" },
|
||||||
|
{ sourceType: "SQUARE_PAYMENT", label: "Transactions", emptyMessage: "No transactions waiting for review — you're all caught up! 🎉" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_SORT: SortOption = { sortBy: "squareCreatedAt", sortDir: "desc" };
|
||||||
|
|
||||||
|
export function PendingQueue() {
|
||||||
|
const [tab, setTab] = useState<SourceType>("SQUARE_INVOICE");
|
||||||
|
const [items, setItems] = useState<Candidate[] | null>(null);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Shared across both tabs (not keyed by tab) so switching tabs doesn't
|
||||||
|
// silently lose whatever sort/filter was chosen — see usePersistedState's
|
||||||
|
// one-time-initializer caveat for why a per-tab key wouldn't reload cleanly
|
||||||
|
// on tab switch anyway.
|
||||||
|
const [sort, setSort] = usePersistedState<SortOption>("pending-sort", DEFAULT_SORT);
|
||||||
|
const [dateRange, setDateRange] = usePersistedState<DateRange>("pending-daterange", {});
|
||||||
|
const [statuses, setStatuses] = usePersistedState<string[]>("pending-statuses", []);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItems(null);
|
||||||
|
setError(null);
|
||||||
|
setPage(1);
|
||||||
|
candidatesApi
|
||||||
|
.list({
|
||||||
|
status: "PENDING",
|
||||||
|
page: 1,
|
||||||
|
sort,
|
||||||
|
sourceType: tab,
|
||||||
|
squareStatus: statuses.length > 0 ? statuses : undefined,
|
||||||
|
dateFrom: dateRange.from ? new Date(dateRange.from) : undefined,
|
||||||
|
dateTo: dateRange.to ? new Date(`${dateRange.to}T23:59:59`) : undefined,
|
||||||
|
search,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setItems(res.items);
|
||||||
|
setTotal(res.total);
|
||||||
|
})
|
||||||
|
.catch(() => setError("Couldn't load pending items."));
|
||||||
|
}, [tab, sort, dateRange, statuses, search]);
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
const res = await candidatesApi.list({
|
||||||
|
status: "PENDING",
|
||||||
|
page: nextPage,
|
||||||
|
sort,
|
||||||
|
sourceType: tab,
|
||||||
|
squareStatus: statuses.length > 0 ? statuses : undefined,
|
||||||
|
dateFrom: dateRange.from ? new Date(dateRange.from) : undefined,
|
||||||
|
dateTo: dateRange.to ? new Date(`${dateRange.to}T23:59:59`) : undefined,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
setItems((prev) => [...(prev ?? []), ...res.items]);
|
||||||
|
setTotal(res.total);
|
||||||
|
setPage(nextPage);
|
||||||
|
} catch {
|
||||||
|
setError("Couldn't load more items.");
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTab = TABS.find((t) => t.sourceType === tab)!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="font-display text-lg text-ink">Ready to review 🎈</h2>
|
||||||
|
<SortPicker value={sort} onChange={setSort} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex gap-1 rounded-full bg-white p-1 shadow-sm">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.sourceType}
|
||||||
|
onClick={() => setTab(t.sourceType)}
|
||||||
|
className={`flex-1 rounded-full py-1.5 text-sm font-bold transition-colors ${
|
||||||
|
tab === t.sourceType ? "bg-gradient-to-r from-party-purple to-party-cyan text-white" : "text-ink-soft"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{tab === t.sourceType && items !== null && ` (${total})`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchBox value={search} onSearch={setSearch} />
|
||||||
|
|
||||||
|
<CandidateFilters
|
||||||
|
showStatusFilter={tab === "SQUARE_INVOICE"}
|
||||||
|
dateRange={dateRange}
|
||||||
|
onDateRangeChange={setDateRange}
|
||||||
|
statuses={statuses}
|
||||||
|
onStatusesChange={setStatuses}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-sm font-semibold text-party-coral">{error}</p>}
|
||||||
|
{items === null && !error && <p className="text-sm text-ink-soft">Loading…</p>}
|
||||||
|
{items?.length === 0 && (
|
||||||
|
<p className="rounded-2xl border-2 border-dashed border-sand-deep bg-white/60 p-6 text-center text-sm text-ink-soft">
|
||||||
|
{search ? "No matches — try a different search. 🔍" : `${activeTab.emptyMessage} Tap "Sync now" above to pull the latest from Square.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items?.map((item) => <CandidateCard key={item.id} candidate={item} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items !== null && items.length < total && (
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="mt-4 w-full rounded-full border-2 border-party-purple/30 bg-white py-2.5 text-sm font-bold text-party-purple-dark disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loadingMore ? "Loading…" : `Load more (${items.length} of ${total})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
web/src/pages/Rejected.tsx
Normal file
11
web/src/pages/Rejected.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { HistoryList } from "./HistoryList";
|
||||||
|
|
||||||
|
export function Rejected() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 font-display text-lg text-ink">Rejected 🙈</h2>
|
||||||
|
<p className="mb-3 text-sm text-ink-soft">Tap an item to view it, then use "Unreject" to send it back to Pending.</p>
|
||||||
|
<HistoryList status="REJECTED" emptyMessage="Nothing rejected — great!" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
web/src/pages/admin/Users.tsx
Normal file
229
web/src/pages/admin/Users.tsx
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { usersApi } from "../../api/resources";
|
||||||
|
import type { Role, User } from "../../api/types";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import { useToast } from "../../components/Toast";
|
||||||
|
|
||||||
|
export function Users() {
|
||||||
|
const showToast = useToast();
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [resettingId, setResettingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
usersApi.list().then(setUsers).catch(() => setError("Couldn't load users."));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(refresh, []);
|
||||||
|
|
||||||
|
async function toggleActive(user: User) {
|
||||||
|
try {
|
||||||
|
await usersApi.update(user.id, { active: !user.active });
|
||||||
|
showToast(user.active ? `${user.name} disabled.` : `${user.name} enabled.`, "success");
|
||||||
|
refresh();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : "Failed to update user";
|
||||||
|
setError(message);
|
||||||
|
showToast(message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRole(user: User) {
|
||||||
|
try {
|
||||||
|
await usersApi.update(user.id, { role: user.role === "ADMIN" ? "USER" : "ADMIN" });
|
||||||
|
showToast(`${user.name} is now ${user.role === "ADMIN" ? "a regular user" : "an admin"}.`, "success");
|
||||||
|
refresh();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : "Failed to update user";
|
||||||
|
setError(message);
|
||||||
|
showToast(message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="font-display text-lg text-ink">Team ⭐</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
className="rounded-full bg-gradient-to-r from-party-purple to-party-cyan px-3 py-1.5 text-xs font-bold text-white shadow-sm"
|
||||||
|
>
|
||||||
|
{showForm ? "Cancel" : "+ New user"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 text-sm font-semibold text-party-coral">{error}</p>}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<CreateUserForm
|
||||||
|
onCreated={(name) => {
|
||||||
|
setShowForm(false);
|
||||||
|
showToast(`Welcome, ${name}! 🎈`, "success");
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{users.map((u) => (
|
||||||
|
<div key={u.id} className="rounded-2xl border border-sand-deep bg-white p-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-ink">{u.name}</p>
|
||||||
|
<p className="text-xs text-ink-soft">{u.email}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-bold ${
|
||||||
|
u.active ? "bg-party-lime/25 text-ink" : "bg-sand-deep text-ink-soft"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.active ? "active" : "disabled"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleRole(u)}
|
||||||
|
className="rounded-full border border-sand-deep px-2.5 py-1 font-semibold text-ink-soft"
|
||||||
|
>
|
||||||
|
Make {u.role === "ADMIN" ? "USER" : "ADMIN"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActive(u)}
|
||||||
|
className="rounded-full border border-sand-deep px-2.5 py-1 font-semibold text-ink-soft"
|
||||||
|
>
|
||||||
|
{u.active ? "Disable" : "Enable"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setResettingId(resettingId === u.id ? null : u.id)}
|
||||||
|
className="rounded-full border border-sand-deep px-2.5 py-1 font-semibold text-ink-soft"
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{resettingId === u.id && (
|
||||||
|
<ResetPasswordForm
|
||||||
|
userId={u.id}
|
||||||
|
onDone={() => {
|
||||||
|
setResettingId(null);
|
||||||
|
showToast("Password reset.", "success");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetPasswordForm({ userId, onDone }: { userId: string; onDone: () => void }) {
|
||||||
|
const showToast = useToast();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await usersApi.resetPassword(userId, password);
|
||||||
|
onDone();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ApiError ? err.message : "Failed to reset password";
|
||||||
|
setError(message);
|
||||||
|
showToast(message, "error");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-2 flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
placeholder="New password"
|
||||||
|
autoFocus
|
||||||
|
className="flex-1 rounded-full border-2 border-ink-soft/70 bg-field px-3 py-1.5 text-xs text-ink outline-none focus:border-party-purple"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="rounded-full bg-gradient-to-r from-party-purple to-party-cyan px-3 py-1.5 text-xs font-bold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? "Saving…" : "Set"}
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-xs font-semibold text-party-coral">{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateUserForm({ onCreated }: { onCreated: (name: string) => void }) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [role, setRole] = useState<Role>("USER");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"mt-1 w-full rounded-xl border-2 border-ink-soft/70 bg-field px-3 py-2 text-sm text-ink outline-none focus:border-party-purple";
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await usersApi.create({ email, name, password, role });
|
||||||
|
onCreated(name);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Failed to create user");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="mb-4 rounded-2xl border border-sand-deep bg-white p-3 shadow-sm">
|
||||||
|
<label className="block text-sm font-semibold text-ink">
|
||||||
|
Name
|
||||||
|
<input required className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="mt-2 block text-sm font-semibold text-ink">
|
||||||
|
Email
|
||||||
|
<input required type="email" className={inputClass} value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="mt-2 block text-sm font-semibold text-ink">
|
||||||
|
Temporary password
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
minLength={8}
|
||||||
|
className={inputClass}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="mt-2 block text-sm font-semibold text-ink">
|
||||||
|
Role
|
||||||
|
<select className={inputClass} value={role} onChange={(e) => setRole(e.target.value as Role)}>
|
||||||
|
<option value="USER">User</option>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{error && <p className="mt-2 text-sm font-semibold text-party-coral">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="mt-3 w-full rounded-full bg-gradient-to-r from-party-purple to-party-cyan py-2 text-sm font-bold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? "Creating…" : "Create user"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
web/tsconfig.json
Normal file
17
web/tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
32
web/vite.config.ts
Normal file
32
web/vite.config.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
includeAssets: ["favicon.svg"],
|
||||||
|
manifest: {
|
||||||
|
name: "InflateHQ — Beach Party Balloons",
|
||||||
|
short_name: "InflateHQ",
|
||||||
|
description: "Review and approve Square invoices/transactions as calendar events",
|
||||||
|
theme_color: "#fef6e4",
|
||||||
|
background_color: "#fef6e4",
|
||||||
|
display: "standalone",
|
||||||
|
icons: [
|
||||||
|
{ src: "pwa-192.png", sizes: "192x192", type: "image/png" },
|
||||||
|
{ src: "pwa-512.png", sizes: "512x512", type: "image/png" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user