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