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:
chris 2026-08-21 15:19:05 -04:00
commit 27589ab99f
68 changed files with 14184 additions and 0 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
**/node_modules
dist
**/dist
.git
.env
*.md
docs

50
.env.example Normal file
View 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
View File

@ -0,0 +1,6 @@
node_modules/
dist/
.env
*.log
.DS_Store
server/prisma/dev.db

35
Dockerfile Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View 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
View 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"
}
}

View 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;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "CalendarCandidate" ADD COLUMN "squareStatus" TEXT;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "CalendarCandidate" ADD COLUMN "squareCreatedAt" TIMESTAMP(3);

View File

@ -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");

View 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
View 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?
}

View 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 };

View 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
View 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
View 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());

View 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 };
}

View 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);
}
}

View 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
View File

@ -0,0 +1,3 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

19
server/src/entrypoint.ts Normal file
View 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
View 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();

View 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
View 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
View 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();
}

View 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)
: [];

View 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}`;
}

View 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)",
);
}

View 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
View 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 };
}

View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

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
View 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
View 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
View 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
View 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;
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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
View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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",
},
},
});