Compare commits

...
15 Commits
Author SHA1 Message Date
soconnorandClaude Opus 4.8 3f3b1362a9 Fix clock-in failing when no client is selected
clockIn and create used `input.clientId?.trim() ?? null`, but clients
always send `""` (not undefined) for no-client. `??` doesn't catch the
empty string, so `""` was inserted into time_entries.client_id,
violating the foreign key to clients.id. Use `|| null` to normalize a
blank string to null, matching updateRunning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:43:21 -04:00
soconnor 40624f6e6f Document recurring invoice scheduler setup 2026-07-02 00:49:48 -04:00
soconnorandClaude Sonnet 5 d589b94580 Make CRON_SECRET optional; fix docker env validation failure
CRON_SECRET was required (min 32 chars) in production by env.js, but
neither docker-compose.yml nor docker-compose.coolify.yml passed it
through. Since the release container isn't covered by
SKIP_ENV_VALIDATION (build-stage only), bun run start failed env
validation on every docker deploy unless CRON_SECRET was manually
configured — even though it only gates the optional recurring-invoice
cron endpoint, which already handles being unset gracefully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:43:20 -04:00
soconnor da219b287e Support mobile home and live activity data 2026-07-01 02:55:16 -04:00
soconnor 316edb4b3e Prefer mobile auth cookie for app requests 2026-06-29 22:01:41 -04:00
soconnor 8866335c50 Accept mobile Better Auth session token header 2026-06-29 18:38:35 -04:00
soconnor fb731885de Support chunked Better Auth session cookies 2026-06-29 17:57:38 -04:00
soconnor 1605322bc9 Improve expense dialog button layout 2026-06-29 15:30:19 -04:00
soconnor ef826dffa9 Harden auth and mobile session handling 2026-06-29 15:21:31 -04:00
soconnor d0c916d659 Polish expenses dashboard 2026-06-29 01:30:20 -04:00
soconnor a66d05f24f Remove default access and secret keys for S3 in Docker Compose files for security enhancement 2026-06-29 01:15:05 -04:00
soconnor 122a3c64f5 Refactor Coolify deployment from MinIO to Garage
- Updated README and documentation to reflect the transition from MinIO to Garage for S3-compatible storage.
- Removed the MinIO-specific docker-compose files and replaced them with Garage configurations.
- Adjusted environment variables and service dependencies in docker-compose files to accommodate Garage.
- Modified application code to log hints for Garage instead of MinIO.
- Ensured all references to MinIO in the codebase and documentation are replaced with Garage.
2026-06-29 00:59:27 -04:00
soconnorandCursor 31151e7f39 Sync time entries with invoice lines and harden auth for mobile.
Link clocked time to invoice items with bidirectional sync, add entry editing on web, broaden session cookie detection for Expo clients, and handle API rate limits without signing users out.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 21:44:19 -04:00
soconnorandCursor c267b3e1c8 Improve expenses receipts UX and Coolify MinIO deployment.
Extract receipt UI components, add view/edit/create dialog modes with list receipt previews, add docker-compose.coolify.yml and clearer COOLIFY/S3 path-style guidance for Application + MinIO setups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 00:47:59 -04:00
soconnor 5e6e51337a enable react compiler 2026-06-27 00:05:42 -04:00
52 changed files with 3160 additions and 747 deletions
+23 -15
View File
@@ -4,10 +4,10 @@
#
# Quick start (local dev):
# cp .env.example .env.local
# docker compose -f docker-compose.dev.yml up -d # Postgres + MinIO
# docker compose -f docker-compose.dev.yml up -d # Postgres + Garage
# bun run db:push # or: bun run db:migrate
# bun run dev
# MinIO console: http://localhost:9001 (minioadmin / minioadmin)
# Garage S3 API: http://localhost:3900
#
# Quick start (Docker app + Postgres):
# cp .env.example .env
@@ -66,8 +66,7 @@ DB_DISABLE_SSL=true
# Dev-only: host ports for `docker compose -f docker-compose.dev.yml`.
POSTGRES_PORT=5432
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
GARAGE_API_PORT=3900
# Optional: if Next dev picks another port, you do not need to change URLs for
# sign-in — the auth client uses window.location.origin in the browser.
@@ -130,20 +129,29 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
# =============================================================================
# Receipt storage — S3-compatible (optional)
# =============================================================================
# When unset, receipt files are stored locally in .data/receipts/ (dev-friendly).
# Works with AWS S3, MinIO, Cloudflare R2, etc.
# When S3_BUCKET + S3_ACCESS_KEY + S3_SECRET_KEY are unset, receipts land in
# .data/receipts/ (dev-friendly). Works with AWS S3, Garage, Cloudflare R2, etc.
#
# Local dev with docker-compose.dev.yml MinIO (host `bun dev`):
S3_ENDPOINT=http://localhost:9000
# S3_ENDPOINT — who can reach Garage?
# • Host dev (bun dev + docker-compose.dev.yml Garage on the host): localhost:3900
# • App in Docker (docker-compose.yml): http://garage:3900 (Compose service name)
# • Coolify — see docs/COOLIFY.md. Summary:
# - Best: one Compose resource with docker-compose.coolify.yml (app+db+garage).
# - Application + separate Garage: ENOTFOUND garage → set S3_ENDPOINT to
# SERVICE_URL_GARAGE_3900 (public domain) OR http://garage-<resource-uuid>:3900
# with Connect to Predefined Network on both resources. Never bare "garage".
# - NEVER use localhost in production — inside the app container that is the app, not Garage.
#
# Local dev with docker-compose.dev.yml Garage (host `bun dev`):
S3_ENDPOINT=http://localhost:3900
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_REGION=us-east-1
S3_ACCESS_KEY=GK3515373e4c851ebaad366558
S3_SECRET_KEY=7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34
S3_REGION=garage
# S3_FORCE_PATH_STYLE=true # default on when S3_ENDPOINT is set; required for Garage/HTTPS proxy
#
# docker-compose.yml sets S3_ENDPOINT=http://minio:9000 inside the app container
# automatically. MINIO_ROOT_* below must match S3_ACCESS_KEY / S3_SECRET_KEY.
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
# docker-compose.yml sets S3_ENDPOINT=http://garage:3900 inside the app container
# automatically. S3_ACCESS_KEY / S3_SECRET_KEY must match the garage service env.
# =============================================================================
# SSO — Authentik OIDC (optional)
-2
View File
@@ -17,10 +17,8 @@ ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
ARG BETTER_AUTH_URL=http://localhost:3000
# Low-memory Docker build profile:
# - disable React Compiler (saves compile RAM; prod image still runs fine without it)
# - skip tsc inside `next build` (run `bun run check` in CI instead)
ENV DOCKER_BUILD=1 \
DISABLE_REACT_COMPILER=1 \
NODE_ENV=production \
SKIP_ENV_VALIDATION=1 \
NEXT_TELEMETRY_DISABLED=1 \
+20 -1
View File
@@ -141,6 +141,18 @@ docker compose build --no-cache app
App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network.
### Scheduled recurring invoices
The app container does not run a cron daemon. It starts the web server with
`bun migrate.ts && bun run start`, and recurring invoice generation only happens
when something calls `POST /api/cron/generate-recurring` with
`Authorization: Bearer $CRON_SECRET`.
- **Coolify deploys:** use a Coolify scheduled task to call the endpoint.
- **Full Docker deploys:** use host cron, a small scheduler sidecar, or an
external scheduler to call
`http://localhost:${WEB_PORT:-${PORT:-3000}}/api/cron/generate-recurring`.
### 3. Updating an existing deploy
```bash
@@ -160,6 +172,10 @@ Prune old app images occasionally: `docker image prune -f` (or remove specific `
To verify migration files match the journal before deploy: `bun run db:verify-journal`.
### Coolify
For self-hosted [Coolify](https://coolify.io) deploys (especially `ENOTFOUND garage` with Application + separate Garage compose), see **[docs/COOLIFY.md](./docs/COOLIFY.md)**. Recommended: deploy [`docker-compose.coolify.yml`](./docker-compose.coolify.yml) as a single Compose resource.
### 4. Sign-ups
Registration is **enabled** by default. To block new email/password accounts:
@@ -190,7 +206,9 @@ beenvoice-web/
├── src/lib/ # auth, PDF, email, branding helpers
├── drizzle/ # SQL migrations
├── Dockerfile # Production image (migrate + next start)
├── docker-compose.yml # App + Postgres (deploy)
├── docker-compose.yml # App + Postgres + Garage (deploy)
├── docker-compose.coolify.yml # Coolify Compose (app + db + garage)
├── docker-compose.coolify-garage.yml # Garage-only for Coolify Application pairing
├── docker-compose.dev.yml # Postgres only (local dev)
└── docs/ # Architecture and UI guides
```
@@ -250,6 +268,7 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
| Doc | Contents |
|-----|----------|
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP |
| [docs/COOLIFY.md](./docs/COOLIFY.md) | Coolify deploy paths and Garage networking |
| [docs/README.md](./docs/README.md) | Index of UI and product guides |
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development |
+74
View File
@@ -0,0 +1,74 @@
# Garage-only stack for Coolify when beenvoice runs as a separate Application resource.
#
# Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-garage.yml
#
# ── Pair with a beenvoice Application (pick ONE) ───────────────────────────────
#
# A) Public Garage URL (most reliable — no shared Docker network required)
# 1. Redeploy this stack (includes SERVICE_FQDN_GARAGE_3900 below).
# 2. Garage resource → assign a domain for port 3900 (e.g. s3.example.com).
# 3. Copy SERVICE_URL_GARAGE_3900 from this resource's Environment tab.
# 4. beenvoice Application → S3_ENDPOINT=<that URL> → redeploy beenvoice.
#
# B) Internal Docker DNS (same Coolify destination network)
# 1. Garage resource → Advanced → enable "Connect to Predefined Network" → redeploy.
# 2. beenvoice Application → same destination → enable "Connect to Predefined Network".
# 3. beenvoice → S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900
#
# Recommended long-term: deploy docker-compose.coolify.yml as one stack (app+db+garage).
# See docs/COOLIFY.md.
services:
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
SERVICE_FQDN_GARAGE_3900:
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
expose:
- "3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "rpc_secret_change_me_in_production"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+124
View File
@@ -0,0 +1,124 @@
# beenvoice on Coolify — single Docker Compose resource (recommended).
#
# Deploy: Coolify → New Resource → Docker Compose → compose file: docker-compose.coolify.yml
#
# 1. Assign a domain to the `app` service in Coolify (SERVICE_FQDN_APP wires Traefik).
# 2. Set AUTH_SECRET, POSTGRES_PASSWORD, S3_ACCESS_KEY, S3_SECRET_KEY in the resource env (see .env.example).
# 3. Do NOT override S3_ENDPOINT — this stack sets http://garage:3900 on the shared network.
# 4. Rebuild after changing NEXT_PUBLIC_* (image build args use SERVICE_URL_APP).
#
# Migrating from Application + separate Postgres + Garage compose:
# - Export Postgres data, point DATABASE_URL at this stack's `db` service, redeploy once here.
# - Or keep external Postgres and remove the `db` service + volume from this file.
services:
app:
build:
context: .
args:
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:3000}}
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:3000}}
image: ${BEENVOICE_IMAGE:-beenvoice:coolify}
environment:
SERVICE_FQDN_APP:
NODE_ENV: production
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:3000}}
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:3000}}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
CRON_SECRET: ${CRON_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
S3_ENDPOINT: http://garage:3900
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-garage}
expose:
- "3000"
depends_on:
db:
condition: service_healthy
garage:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
SERVICE_FQDN_GARAGE_3900:
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
expose:
- "3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes:
beenvoice_pg_data:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "rpc_secret_change_me_in_production"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+45 -28
View File
@@ -17,41 +17,58 @@ services:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped
# S3-compatible receipt storage for host dev (`bun dev`). API :9000, console :9001.
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
# S3-compatible receipt storage for host dev (`bun dev`). API :3900.
garage:
image: dxflrs/garage:v2.3.0
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_dev_minio_data:/data
command: server /data --console-address ":9001"
- beenvoice_dev_garage_meta:/var/lib/garage/meta
- beenvoice_dev_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
- "${GARAGE_API_PORT:-3900}:3900"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 10
retries: 15
start_period: 20s
restart: unless-stopped
minio-init:
image: minio/mc:RELEASE.2025-04-22T16-22-07Z
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb local/$$S3_BUCKET --ignore-existing
"
restart: "no"
volumes:
beenvoice_dev_pg_data:
beenvoice_dev_minio_data:
beenvoice_dev_garage_meta:
beenvoice_dev_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+56 -36
View File
@@ -1,4 +1,8 @@
# Production stack (app + Postgres + MinIO). Local dev Postgres/MinIO: docker-compose.dev.yml
# Production stack (app + Postgres + Garage). Local dev Postgres/Garage: docker-compose.dev.yml
#
# Coolify: deploy docker-compose.coolify.yml as ONE Docker Compose resource (preferred),
# or this file. S3_ENDPOINT=http://garage:3900 works only inside a single stack.
# Application + separate Garage → docs/COOLIFY.md.
#
# After git pull, rebuild before starting — a plain `docker compose up -d` reuses
# the existing local image and will NOT include new code. Use:
@@ -27,24 +31,23 @@ services:
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
CRON_SECRET: ${CRON_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
S3_ENDPOINT: http://minio:9000
S3_ENDPOINT: http://garage:3900
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
S3_REGION: ${S3_REGION:-us-east-1}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
S3_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
S3_REGION: ${S3_REGION:-garage}
ports:
- "${WEB_PORT:-${PORT:-3000}}:3000"
depends_on:
db:
condition: service_healthy
minio:
garage:
condition: service_healthy
minio-init:
condition: service_completed_successfully
restart: unless-stopped
db:
@@ -63,41 +66,58 @@ services:
retries: 10
restart: unless-stopped
# S3-compatible receipt storage. API :9000, web console :9001 (host-mapped in dev compose).
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
# S3-compatible receipt storage (~50100 MB RAM vs MinIO). API :3900.
garage:
image: dxflrs/garage:v2.3.0
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_minio_data:/data
command: server /data --console-address ":9001"
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
- "${GARAGE_API_PORT:-3900}:3900"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 10
retries: 15
start_period: 20s
restart: unless-stopped
minio-init:
image: minio/mc:RELEASE.2025-04-22T16-22-07Z
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb local/$$S3_BUCKET --ignore-existing
"
restart: "no"
volumes:
beenvoice_pg_data:
beenvoice_minio_data:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+137
View File
@@ -0,0 +1,137 @@
# Coolify deployment — beenvoice + Garage
beenvoice stores receipt files in S3-compatible storage when `S3_BUCKET`, `S3_ACCESS_KEY`, and `S3_SECRET_KEY` are set. [Garage](https://garagehq.deuxfleurs.fr/) is the default on self-hosted Coolify (~50100 MB RAM vs MinIO's ~500 MB+).
## Why `getaddrinfo ENOTFOUND garage` happens
Docker DNS resolves service names **only inside the same Docker network**.
| Setup | Does `http://garage:3900` work? |
|-------|--------------------------------|
| Single Compose stack (app + garage together) | Yes — Compose service name `garage` |
| beenvoice **Application** + Garage **separate Compose** | **No** — each resource has its own network by default |
| Application + Garage with shared destination network + correct hostname | Yes — hostname is usually **`garage-<resource-uuid>`**, not bare `garage` |
| Application + Garage via **public domain** (`SERVICE_URL_GARAGE_3900`) | Yes — no Docker DNS needed |
Setting `S3_ENDPOINT=http://garage:3900` on a standalone beenvoice Application fails because the app container is not on the Garage stack's network. Node returns `ENOTFOUND garage`.
Also avoid `http://localhost:3900` inside the app container — that points at the app itself, not Garage.
---
## Quick fix — keep beenvoice as Application + separate Garage compose
Use this if you are **not** migrating to a single Compose stack today.
### Path A — public Garage URL (recommended, works without shared Docker network)
This is the most reliable fix when beenvoice is a Coolify **Application** (Dockerfile) and Garage is a separate Compose resource.
1. **Update the Garage stack** to the latest `docker-compose.coolify-garage.yml` from this repo (includes `SERVICE_FQDN_GARAGE_3900`) and **redeploy** the Garage resource.
2. In the **Garage Compose resource** → assign a domain for **port 3900** (e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy.
3. Open the Garage resource **Environment** tab and copy **`SERVICE_URL_GARAGE_3900`** (e.g. `https://s3.yourdomain.com`).
4. On the **beenvoice Application** → Environment:
```env
S3_ENDPOINT=https://s3.yourdomain.com
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=<same as GARAGE_DEFAULT_ACCESS_KEY / S3_ACCESS_KEY on Garage stack>
S3_SECRET_KEY=<same as GARAGE_DEFAULT_SECRET_KEY / S3_SECRET_KEY on Garage stack>
S3_REGION=garage
```
5. **Redeploy beenvoice** (restart is not enough after env changes on some Coolify versions — trigger a full redeploy).
`S3_FORCE_PATH_STYLE` defaults to on when `S3_ENDPOINT` is set (required for Garage behind a reverse proxy). Only set `S3_FORCE_PATH_STYLE=false` if you use AWS S3 with virtual-hosted-style buckets.
### Path B — internal Docker DNS (same destination, no public Garage domain)
Use when you want S3 API traffic to stay on the Docker network.
1. Put beenvoice Application and Garage Compose in the **same Coolify project** and **same destination** (server/network).
2. **Garage Compose resource****Advanced** → enable **Connect to Predefined Network****redeploy Garage**.
3. **beenvoice Application****Advanced** → enable **Connect to Predefined Network** (same destination) → **redeploy beenvoice**.
4. Find the Garage resource **UUID** (in the Coolify URL, e.g. `.../service/abc123def456`, or env `COOLIFY_RESOURCE_UUID` on the Garage container).
5. Set on beenvoice Application:
```env
S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900
```
Example: resource UUID `k8w2o0g4s0g8``S3_ENDPOINT=http://garage-k8w2o0g4s0g8:3900`.
**Do not use bare `garage`** unless you verified it resolves from inside the beenvoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://garage:3900` fails, use the `garage-<uuid>` form or Path A).
6. Match credentials and bucket:
```env
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=<S3_ACCESS_KEY on Garage stack>
S3_SECRET_KEY=<S3_SECRET_KEY on Garage stack>
S3_REGION=garage
```
---
## Recommended long-term — one Compose stack
Deploy **[`docker-compose.coolify.yml`](../docker-compose.coolify.yml)** as **one** Coolify **Docker Compose** resource (app + Postgres + Garage). This is the lowest-friction production layout on Coolify.
1. Coolify → **New Resource****Docker Compose**
2. Point at this repo; compose file: **`docker-compose.coolify.yml`**
3. Set env vars from [`.env.example`](../.env.example): `AUTH_SECRET`, `POSTGRES_PASSWORD`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, etc.
4. Assign a domain to the **`app`** service (Coolify fills `SERVICE_URL_APP` / `BETTER_AUTH_URL` automatically).
5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://garage:3900` on the shared network.
6. Redeploy.
Alternative: [`docker-compose.yml`](../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/Garage.
### Migrating from Application + external Postgres + Garage (or legacy MinIO)
| Current | Action |
|---------|--------|
| beenvoice Application | Remove after Compose stack is live |
| Separate Postgres | Dump/restore into stack `db`, or keep external DB and delete the `db` service from the compose file |
| Garage / MinIO compose | Remove after data migrated (rclone) or re-point receipts (new bucket) |
| Env vars | Move `AUTH_SECRET`, Resend, Authentik, etc. to the Compose resource env |
**Migrating from MinIO:** Garage uses port **3900** (not 9000) and Garage-format access keys (`GK…`). Update `S3_ENDPOINT`, `S3_REGION=garage`, and credentials. Receipt blobs in the old MinIO volume are not auto-migrated.
---
## Compose file reference
| File | Purpose |
|------|---------|
| [`docker-compose.coolify.yml`](../docker-compose.coolify.yml) | **Recommended** — full stack for one Coolify Compose resource |
| [`docker-compose.yml`](../docker-compose.yml) | Full stack (local/VPS); also valid on Coolify |
| [`docker-compose.coolify-garage.yml`](../docker-compose.coolify-garage.yml) | Garage only; pair with beenvoice Application (Path A or B above) |
Do **not** add `networks: coolify: external: true` unless you know the exact external network name on your server. Coolify v4 uses **destinations**; network names are often UUID-based. Prefer the UI **Connect to Predefined Network** toggle over hard-coding `coolify` in compose.
---
## Checklist (Application + separate Garage)
- [ ] Garage stack redeployed with current `docker-compose.coolify-garage.yml`
- [ ] **Path A:** domain on port 3900 + `S3_ENDPOINT` = `SERVICE_URL_GARAGE_3900`
**or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://garage-<uuid>:3900`
- [ ] `S3_ENDPOINT` is **not** `http://garage:3900`, **not** `localhost`
- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match the Garage stack env
- [ ] `S3_BUCKET` exists (Garage `--default-bucket` creates `beenvoice-receipts` on first start)
- [ ] Redeployed beenvoice after env or network changes
## Verify from the beenvoice container
```bash
# Shell into beenvoice app container on the Coolify server
docker exec -it <beenvoice-container> sh
# Path A — public URL (403/404 on root is fine — confirms DNS + TLS)
wget -qO- "https://s3.yourdomain.com" || curl -sf "https://s3.yourdomain.com"
# Path B — internal host from S3_ENDPOINT
wget -qO- "http://garage-<uuid>:3900" || curl -sf "http://garage-<uuid>:3900"
```
If this fails with "bad address" or timeout, fix networking / `S3_ENDPOINT` before debugging app code. On first S3 use, the app logs a hint if DNS fails or if `S3_ENDPOINT` still uses bare `garage` in production.
+1
View File
@@ -8,6 +8,7 @@
|----------|-------------|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract |
| [../README.md](../README.md) | Install, scripts, deployment |
| [COOLIFY.md](./COOLIFY.md) | Coolify + Garage networking (`ENOTFOUND garage`) |
## UI & product guides
+11
View File
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_invoice_item" ADD COLUMN IF NOT EXISTS "timeEntryId" varchar(255);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk'
) THEN
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk" FOREIGN KEY ("timeEntryId") REFERENCES "public"."beenvoice_time_entry"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "invoice_item_time_entry_id_idx" ON "beenvoice_invoice_item" USING btree ("timeEntryId") WHERE "timeEntryId" is not null;
+3 -3
View File
@@ -157,10 +157,10 @@
"breakpoints": true
},
{
"idx": 22,
"idx": 23,
"version": "7",
"when": 1782100000000,
"tag": "0022_expense_business_receipts",
"when": 1782200000000,
"tag": "0023_invoice_item_time_entry",
"breakpoints": true
}
]
+2 -2
View File
@@ -3,8 +3,8 @@ set -euo pipefail
# Production deploy helper for docker-compose.yml (not docker-compose.dev.yml).
# Rebuilds the app image from the current working tree, then starts/restarts services
# (app, db, minio, minio-init). Receipt storage uses in-stack MinIO unless S3_* are
# overridden in .env. MinIO API/console: localhost:${MINIO_API_PORT:-9000} / :9001.
# (app, db, garage). Receipt storage uses in-stack Garage unless S3_* are
# overridden in .env. Garage S3 API: localhost:${GARAGE_API_PORT:-3900}.
#
# Plain `docker compose up -d` reuses the local image tag and does NOT pick up
# changes from `git pull`. Always pass --build or use this script after pulling.
+19 -2
View File
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
import { sendPasswordResetForUser } from "~/lib/password-reset";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
export async function POST(request: NextRequest) {
try {
@@ -12,8 +13,24 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
const normalizedEmail = email.toLowerCase().trim();
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:forgot"), {
windowMs: 60 * 60 * 1000,
max: 10,
});
if (ipRateLimit) return ipRateLimit;
const emailRateLimit = requireRateLimit(
rateLimitKey(request, "auth:forgot-email", normalizedEmail),
{
windowMs: 60 * 60 * 1000,
max: 3,
},
);
if (emailRateLimit) return emailRateLimit;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
if (!emailRegex.test(normalizedEmail)) {
return NextResponse.json(
{ error: "Invalid email format" },
{ status: 400 },
@@ -21,7 +38,7 @@ export async function POST(request: NextRequest) {
}
const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
where: eq(users.email, normalizedEmail),
columns: { id: true },
});
+17 -1
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { auth } from "~/lib/auth";
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
import { resolveNewUserRole } from "~/lib/first-admin";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { env } from "~/env";
import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema";
@@ -71,6 +72,12 @@ function formatRegisterError(error: z.ZodError): string {
export async function POST(request: NextRequest) {
try {
const rateLimit = requireRateLimit(rateLimitKey(request, "auth:register"), {
windowMs: 60 * 60 * 1000,
max: 5,
});
if (rateLimit) return rateLimit;
if (env.DISABLE_SIGNUPS === true) {
return NextResponse.json(
{ error: "New account registration is currently disabled" },
@@ -106,13 +113,22 @@ export async function POST(request: NextRequest) {
const { firstName, lastName, email, password } = parsed.data;
const normalizedEmail = email.toLowerCase();
const emailRateLimit = requireRateLimit(
rateLimitKey(request, "auth:register-email", normalizedEmail),
{
windowMs: 60 * 60 * 1000,
max: 3,
},
);
if (emailRateLimit) return emailRateLimit;
const existingUser = await db.query.users.findFirst({
where: eq(users.email, normalizedEmail),
});
if (existingUser) {
return NextResponse.json(
{ error: "User with this email already exists" },
{ error: "Registration failed. Please check the form or sign in." },
{ status: 400 },
);
}
+23 -1
View File
@@ -1,11 +1,20 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { hashPasswordResetToken } from "~/lib/reset-token";
import { revokeUserSessions } from "~/lib/session-security";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema";
export async function POST(request: NextRequest) {
try {
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:reset"), {
windowMs: 60 * 1000,
max: 10,
});
if (ipRateLimit) return ipRateLimit;
const { token, password } = (await request.json()) as {
token: string;
password: string;
@@ -29,10 +38,21 @@ export async function POST(request: NextRequest) {
);
}
const tokenRateLimit = requireRateLimit(
rateLimitKey(request, "auth:reset-token", token),
{
windowMs: 60 * 60 * 1000,
max: 5,
},
);
if (tokenRateLimit) return tokenRateLimit;
const tokenHash = hashPasswordResetToken(token);
// Find user with valid reset token that hasn't expired
const user = await db.query.users.findFirst({
where: and(
eq(users.resetToken, token),
eq(users.resetToken, tokenHash),
gt(users.resetTokenExpiry, new Date()),
),
});
@@ -82,6 +102,8 @@ export async function POST(request: NextRequest) {
}
});
await revokeUserSessions(user.id);
return NextResponse.json(
{
success: true,
+20 -1
View File
@@ -1,20 +1,39 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm";
import { hashPasswordResetToken } from "~/lib/reset-token";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
export async function POST(request: NextRequest) {
try {
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:validate-reset"), {
windowMs: 60 * 1000,
max: 20,
});
if (ipRateLimit) return ipRateLimit;
const { token } = (await request.json()) as { token: string };
if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Token is required" }, { status: 400 });
}
const tokenRateLimit = requireRateLimit(
rateLimitKey(request, "auth:validate-reset-token", token),
{
windowMs: 60 * 60 * 1000,
max: 5,
},
);
if (tokenRateLimit) return tokenRateLimit;
const tokenHash = hashPasswordResetToken(token);
// Find user with valid reset token that hasn't expired
const user = await db.query.users.findFirst({
where: and(
eq(users.resetToken, token),
eq(users.resetToken, tokenHash),
gt(users.resetTokenExpiry, new Date()),
),
});
+8 -1
View File
@@ -7,7 +7,14 @@ export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
const secret = env.CRON_SECRET;
if (secret && authHeader !== `Bearer ${secret}`) {
if (!secret) {
return NextResponse.json(
{ error: "Cron secret is not configured" },
{ status: 500 },
);
}
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { getOptionalServerSession } from "~/lib/auth-server";
import { getObject } from "~/lib/object-storage";
@@ -20,7 +20,7 @@ export async function GET(
with: { expense: true },
});
if (!receipt || receipt.expense.createdById !== session.user.id) {
if (receipt?.expense.createdById !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
+12 -4
View File
@@ -15,6 +15,7 @@ import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { env } from "~/env";
import { authClient } from "~/lib/auth-client";
import { safeCallbackPath } from "~/lib/safe-callback-url";
import { toast } from "sonner";
interface SignInFormProps {
@@ -25,7 +26,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const callbackUrl = safeCallbackPath(searchParams.get("callbackUrl"));
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
@@ -39,10 +40,17 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
setLoading(false);
if (error) {
const message = error.message?.toLowerCase() ?? "";
const rateLimited =
error.status === 429 ||
message.includes("too many") ||
message.includes("rate limit");
toast.error(
error.message && error.message !== "Required"
? error.message
: "Invalid email or password",
rateLimited
? "Too many sign-in attempts. Please wait a moment and try again."
: error.message && error.message !== "Required"
? error.message
: "Invalid email or password",
);
return;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { FileText, Loader2, Paperclip } from "lucide-react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
import { isImageReceipt, receiptUrl } from "~/components/expenses/receipt-utils";
import { cn } from "~/lib/utils";
interface ReceiptPreview {
id: string;
mimeType: string;
originalFilename: string;
}
interface ExpenseReceiptIndicatorProps {
expenseId: string;
receiptCount: number;
receiptPreview: ReceiptPreview | null;
className?: string;
}
export function ExpenseReceiptIndicator({
expenseId,
receiptCount,
receiptPreview,
className,
}: ExpenseReceiptIndicatorProps) {
const [listOpen, setListOpen] = useState(false);
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
null,
);
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
{ expenseId },
{ enabled: listOpen && receiptCount > 1 },
);
if (receiptCount === 0) {
return (
<span className={cn("text-muted-foreground text-xs", className)}></span>
);
}
const handleClick = () => {
if (receiptCount === 1 && receiptPreview) {
setViewerReceipt({
id: receiptPreview.id,
originalFilename: receiptPreview.originalFilename,
mimeType: receiptPreview.mimeType,
});
return;
}
setListOpen(true);
};
const previewIsImage =
receiptPreview && isImageReceipt(receiptPreview.mimeType);
return (
<>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClick}
className={cn(
"hover:bg-muted h-auto gap-2 px-2 py-1.5 font-normal",
className,
)}
title={
receiptCount === 1
? "View receipt"
: `View ${receiptCount} receipts`
}
>
{previewIsImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={receiptUrl(receiptPreview.id)}
alt=""
className="h-8 w-8 rounded object-cover ring-1 ring-black/5"
/>
) : (
<div className="bg-muted flex h-8 w-8 items-center justify-center rounded ring-1 ring-black/5">
<FileText className="text-muted-foreground h-4 w-4" />
</div>
)}
<span className="text-muted-foreground flex items-center gap-1 text-xs">
<Paperclip className="h-3 w-3" />
{receiptCount}
</span>
</Button>
<ReceiptViewerDialog
receipt={viewerReceipt}
open={!!viewerReceipt}
onOpenChange={(open) => {
if (!open) setViewerReceipt(null);
}}
/>
<Dialog open={listOpen} onOpenChange={setListOpen}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>Receipts ({receiptCount})</DialogTitle>
</DialogHeader>
<div className="space-y-2">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading receipts
</div>
) : (
receipts.map((receipt) => (
<ExpenseReceiptItem
key={receipt.id}
receipt={receipt}
expenseId={expenseId}
onView={(r) => {
setListOpen(false);
setViewerReceipt(r);
}}
compact
/>
))
)}
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { ExternalLink, Eye, FileText, Loader2, Trash2 } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import {
formatReceiptSize,
isImageReceipt,
receiptUrl,
} from "~/components/expenses/receipt-utils";
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
export interface ExpenseReceiptRecord {
id: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
}
interface ExpenseReceiptItemProps {
receipt: ExpenseReceiptRecord;
expenseId: string;
onView: (receipt: ReceiptViewerTarget) => void;
compact?: boolean;
readOnly?: boolean;
}
export function ExpenseReceiptItem({
receipt,
expenseId,
onView,
compact = false,
readOnly = false,
}: ExpenseReceiptItemProps) {
const [confirmDelete, setConfirmDelete] = useState(false);
const utils = api.useUtils();
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
onSuccess: () => {
toast.success("Receipt removed");
void utils.expenses.listReceipts.invalidate({ expenseId });
void utils.expenses.getAll.invalidate();
setConfirmDelete(false);
},
onError: (e) => toast.error(e.message),
});
const url = receiptUrl(receipt.id);
const isImage = isImageReceipt(receipt.mimeType);
return (
<>
<div
className={
compact
? "flex items-center gap-2 rounded-md border p-2"
: "flex items-center gap-3 rounded-md border p-2 sm:p-3"
}
>
<button
type="button"
onClick={() => onView(receipt)}
className="hover:ring-primary/40 focus-visible:ring-ring shrink-0 overflow-hidden rounded transition hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
aria-label={`View ${receipt.originalFilename}`}
>
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt=""
className={
compact ? "h-10 w-10 object-cover" : "h-12 w-12 object-cover sm:h-14 sm:w-14"
}
/>
) : (
<div
className={
compact
? "bg-muted flex h-10 w-10 items-center justify-center"
: "bg-muted flex h-12 w-12 items-center justify-center sm:h-14 sm:w-14"
}
>
<FileText className="text-muted-foreground h-5 w-5 sm:h-6 sm:w-6" />
</div>
)}
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{receipt.originalFilename}
</p>
<p className="text-muted-foreground text-xs">
{formatReceiptSize(receipt.sizeBytes)}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onView(receipt)}
title="View receipt"
>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" asChild>
<a href={url} target="_blank" rel="noreferrer" title="Open in new tab">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
{!readOnly && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive h-8 w-8 p-0"
onClick={() => setConfirmDelete(true)}
disabled={deleteReceipt.isPending}
title="Delete receipt"
>
{deleteReceipt.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete receipt?</AlertDialogTitle>
<AlertDialogDescription>
&ldquo;{receipt.originalFilename}&rdquo; will be permanently
removed. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteReceipt.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteReceipt.isPending}
onClick={(e) => {
e.preventDefault();
deleteReceipt.mutate({ id: receipt.id });
}}
>
{deleteReceipt.isPending ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,166 @@
"use client";
import { useRef, useState } from "react";
import { Loader2, Paperclip } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { Label } from "~/components/ui/label";
import { FileUpload } from "~/components/forms/file-upload";
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
import {
fileToBase64,
RECEIPT_ACCEPT,
RECEIPT_MAX_SIZE,
RECEIPT_UPLOAD_HINT,
} from "~/components/expenses/receipt-utils";
interface ExpenseReceiptsPanelProps {
expenseId: string | null;
readOnly?: boolean;
}
export function ExpenseReceiptsPanel({
expenseId,
readOnly = false,
}: ExpenseReceiptsPanelProps) {
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
null,
);
const [uploadKey, setUploadKey] = useState(0);
const processedFileCountRef = useRef(0);
const utils = api.useUtils();
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
{ expenseId: expenseId! },
{ enabled: !!expenseId },
);
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
onSuccess: () => {
if (expenseId) {
void utils.expenses.listReceipts.invalidate({ expenseId });
void utils.expenses.getAll.invalidate();
}
},
onError: (e) => toast.error(e.message),
});
const handleFiles = async (files: File[]) => {
if (!expenseId || files.length === 0) return;
const newFiles = files.slice(processedFileCountRef.current);
processedFileCountRef.current = files.length;
if (newFiles.length === 0) return;
let uploaded = 0;
for (const file of newFiles) {
try {
const data = await fileToBase64(file);
await uploadReceipt.mutateAsync({
expenseId,
filename: file.name,
mimeType: file.type || "application/octet-stream",
data,
});
uploaded++;
} catch (error) {
const message =
error instanceof Error ? error.message : "Upload failed";
toast.error(`${file.name}: ${message}`);
}
}
if (uploaded > 0) {
toast.success(
uploaded === 1 ? "Receipt uploaded" : `${uploaded} receipts uploaded`,
);
processedFileCountRef.current = 0;
setUploadKey((k) => k + 1);
}
};
if (!expenseId) {
return (
<div className="space-y-2 border-t pt-4">
<Label className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
Receipts
</Label>
<div className="bg-muted/40 text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
Save the expense first, then drag and drop receipts here.
</div>
</div>
);
}
return (
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between gap-2">
<Label className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
Receipts
{receipts.length > 0 && (
<span className="text-muted-foreground text-xs font-normal">
({receipts.length})
</span>
)}
</Label>
{uploadReceipt.isPending && (
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Uploading
</span>
)}
</div>
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading receipts
</div>
) : receipts.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
{readOnly
? "No receipts attached."
: "No receipts yet. Drop images or PDFs below."}
</div>
) : (
<div className="space-y-2">
{receipts.map((receipt) => (
<ExpenseReceiptItem
key={receipt.id}
receipt={receipt}
expenseId={expenseId}
onView={setViewerReceipt}
readOnly={readOnly}
/>
))}
</div>
)}
{!readOnly && (
<FileUpload
key={`${expenseId}-${uploadKey}`}
onFilesSelected={(files) => void handleFiles(files)}
accept={RECEIPT_ACCEPT}
maxFiles={5}
maxSize={RECEIPT_MAX_SIZE}
disabled={uploadReceipt.isPending}
placeholder="Drop receipts here or tap to browse"
description={RECEIPT_UPLOAD_HINT}
className="[&>div:first-child]:p-4 sm:[&>div:first-child]:p-6"
/>
)}
<ReceiptViewerDialog
receipt={viewerReceipt}
open={!!viewerReceipt}
onOpenChange={(open) => {
if (!open) setViewerReceipt(null);
}}
/>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
export const RECEIPT_ACCEPT: Record<string, string[]> = {
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"],
"application/pdf": [".pdf"],
};
export const RECEIPT_MAX_SIZE = 10 * 1024 * 1024;
export const RECEIPT_UPLOAD_HINT =
"PNG, JPG, or PDF · up to 10MB each";
export function receiptUrl(receiptId: string) {
return `/api/receipts/${receiptId}`;
}
export function isImageReceipt(mimeType: string) {
return mimeType.startsWith("image/");
}
export function isPdfReceipt(mimeType: string) {
return mimeType === "application/pdf";
}
export function formatReceiptSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export async function fileToBase64(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
if (!base64) {
reject(new Error("Failed to read file"));
return;
}
resolve(base64);
};
reader.onerror = () =>
reject(reader.error instanceof Error ? reader.error : new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}
@@ -0,0 +1,92 @@
"use client";
import { ExternalLink, FileText } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
isImageReceipt,
isPdfReceipt,
receiptUrl,
} from "~/components/expenses/receipt-utils";
export interface ReceiptViewerTarget {
id: string;
originalFilename: string;
mimeType: string;
}
interface ReceiptViewerDialogProps {
receipt: ReceiptViewerTarget | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ReceiptViewerDialog({
receipt,
open,
onOpenChange,
}: ReceiptViewerDialogProps) {
if (!receipt) return null;
const url = receiptUrl(receipt.id);
const isImage = isImageReceipt(receipt.mimeType);
const isPdf = isPdfReceipt(receipt.mimeType);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-4">
<DialogHeader className="shrink-0">
<DialogTitle className="truncate pr-8">
{receipt.originalFilename}
</DialogTitle>
</DialogHeader>
<div className="bg-muted/30 min-h-[200px] flex-1 overflow-auto rounded-md border">
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={receipt.originalFilename}
className="mx-auto max-h-[min(70vh,720px)] w-full object-contain"
/>
) : isPdf ? (
<iframe
src={url}
title={receipt.originalFilename}
className="h-[min(70vh,720px)] w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-48 flex-col items-center justify-center gap-3 p-6 text-center text-sm">
<FileText className="h-10 w-10" />
<p>Preview not available for this file type.</p>
<Button variant="outline" size="sm" asChild>
<a href={url} target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open file
</a>
</Button>
</div>
)}
</div>
<DialogFooter className="shrink-0 sm:justify-between">
<Button variant="outline" asChild>
<a href={url} target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open in new tab
</a>
</Button>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -53,7 +53,7 @@ export function AppearanceProviderSynced({
if (!serverColorMode?.colorMode) return;
if (serverHydratedRef.current) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(serverColorMode.colorMode);
serverHydratedRef.current = true;
}, [serverColorMode?.colorMode]);
+89 -1
View File
@@ -37,11 +37,68 @@ import {
} from "~/lib/time-clock";
import { invoiceLabel } from "~/lib/time-entry-display";
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
const FEATURED_CLIENT_COUNT = 4;
type StartMode = "now" | "pick" | "ago";
function toDatetimeLocalValue(value: Date | string) {
const start = new Date(value);
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
return start.toISOString().slice(0, 16);
}
function RunningTextFields({
running,
updateRunningPending,
onDescriptionCommit,
onStartedAtCommit,
}: {
running: { id: string; description: string | null; startedAt: Date };
updateRunningPending: boolean;
onDescriptionCommit: (description: string) => void;
onStartedAtCommit: (startedAt: Date) => void;
}) {
const [title, setTitle] = useState(running.description ?? "");
const [runningStartedAt, setRunningStartedAt] = useState(() =>
toDatetimeLocalValue(running.startedAt),
);
return (
<>
<div className="space-y-2">
<Label htmlFor="clock-running-title">What are you working on?</Label>
<Input
id="clock-running-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => onDescriptionCommit(title)}
placeholder="What are you working on?"
/>
</div>
<div className="space-y-2">
<Label htmlFor="clock-running-start">Started at</Label>
<Input
id="clock-running-start"
type="datetime-local"
value={runningStartedAt}
onChange={(e) => {
const value = e.target.value;
setRunningStartedAt(value);
if (!value) return;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime()) || parsed > new Date()) return;
onStartedAtCommit(parsed);
}}
disabled={updateRunningPending}
/>
</div>
</>
);
}
export type TimeClockPanelProps = {
defaultClientId?: string;
defaultInvoiceId?: string;
@@ -109,6 +166,7 @@ export function TimeClockPanel({
const [startMode, setStartMode] = useState<StartMode>("now");
const [pickedStart, setPickedStart] = useState("");
const [minutesAgo, setMinutesAgo] = useState("30");
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const draftClientId = running ? (running.clientId ?? "") : clientId;
@@ -185,6 +243,18 @@ export function TimeClockPanel({
onError: (e) => toast.error(e.message),
});
function handleRunningDescriptionCommit(nextTitle: string) {
if (!running) return;
const next = resolveClockDescription(nextTitle);
if (next === (running.description ?? "")) return;
updateRunning.mutate({ description: next });
}
function handleRunningStartedAtCommit(parsed: Date) {
if (!running) return;
updateRunning.mutate({ startedAt: parsed });
}
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: (data) => {
const message = describeClockOutOutcome({
@@ -496,6 +566,14 @@ export function TimeClockPanel({
</>
) : (
<>
<RunningTextFields
key={running.id}
running={running}
updateRunningPending={updateRunning.isPending}
onDescriptionCommit={handleRunningDescriptionCommit}
onStartedAtCommit={handleRunningStartedAtCommit}
/>
<div className="space-y-2">
<Label>Client</Label>
<div className="flex flex-wrap gap-2">
@@ -621,7 +699,10 @@ export function TimeClockPanel({
</CardHeader>
<CardContent>
{todayEntries?.some((e) => e.endedAt) ? (
<TimeEntryList entries={todayEntries} />
<TimeEntryList
entries={todayEntries}
onEdit={(entry) => setEditEntryId(entry.id)}
/>
) : (
<p className="text-muted-foreground py-4 text-center text-sm">
No entries today.{" "}
@@ -637,6 +718,13 @@ export function TimeClockPanel({
</Card>
) : null}
<TimeEntryEditDialog
entryId={editEntryId}
open={editEntryId != null}
onOpenChange={(open) => {
if (!open) setEditEntryId(null);
}}
/>
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
@@ -9,9 +9,12 @@ import { EmptyState } from "~/components/layout/page-layout";
import { Clock, Play } from "lucide-react";
import { groupEntriesByDate } from "~/lib/time-entry-display";
import { TimeEntryRow } from "~/components/time-clock/time-entry-list";
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
import type { TimeEntryListItem } from "~/lib/time-entry-display";
export function TimeEntriesHistory() {
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const completedEntries = useMemo(
() => (entries ?? []).filter((e) => e.endedAt),
@@ -57,25 +60,35 @@ export function TimeEntriesHistory() {
}
return (
<div className="space-y-6">
{grouped.map((group) => (
<Card key={group.dateKey}>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
{group.label}
</CardTitle>
</CardHeader>
<CardContent>
{group.entries.map((entry, index) => (
<TimeEntryRow
key={entry.id}
entry={entry}
isLast={index === group.entries.length - 1}
/>
))}
</CardContent>
</Card>
))}
</div>
<>
<div className="space-y-6">
{grouped.map((group) => (
<Card key={group.dateKey}>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
{group.label}
</CardTitle>
</CardHeader>
<CardContent>
{group.entries.map((entry, index) => (
<TimeEntryRow
key={entry.id}
entry={entry}
isLast={index === group.entries.length - 1}
onEdit={(item: TimeEntryListItem) => setEditEntryId(item.id)}
/>
))}
</CardContent>
</Card>
))}
</div>
<TimeEntryEditDialog
entryId={editEntryId}
open={editEntryId != null}
onOpenChange={(open) => {
if (!open) setEditEntryId(null);
}}
/>
</>
);
}
@@ -0,0 +1,274 @@
"use client";
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { toast } from "sonner";
import { invoiceLabel } from "~/lib/time-entry-display";
import type { RouterOutputs } from "~/trpc/react";
type TimeEntry = RouterOutputs["timeEntries"]["getById"];
function toDatetimeLocalValue(value: Date | string) {
const start = new Date(value);
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
return start.toISOString().slice(0, 16);
}
export type TimeEntryEditDialogProps = {
entryId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
};
type TimeEntryEditFormProps = {
entry: TimeEntry;
entryId: string;
clients: RouterOutputs["clients"]["getAll"];
onClose: () => void;
};
function TimeEntryEditForm({
entry,
entryId,
clients,
onClose,
}: TimeEntryEditFormProps) {
const utils = api.useUtils();
const [description, setDescription] = useState(entry.description ?? "");
const [clientId, setClientId] = useState(entry.clientId ?? "");
const [invoiceId, setInvoiceId] = useState(entry.invoiceId ?? "");
const [rate, setRate] = useState(entry.rate ?? 0);
const [startedAt, setStartedAt] = useState(() => toDatetimeLocalValue(entry.startedAt));
const [endedAt, setEndedAt] = useState(() =>
entry.endedAt ? toDatetimeLocalValue(entry.endedAt) : "",
);
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
clientId ? { clientId } : undefined,
{ enabled: Boolean(clientId) },
);
const hoursPreview = useMemo(() => {
if (!startedAt || !endedAt) return null;
const start = new Date(startedAt);
const end = new Date(endedAt);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null;
return Math.max(0, (end.getTime() - start.getTime()) / 3_600_000);
}, [endedAt, startedAt]);
const updateEntry = api.timeEntries.update.useMutation({
onSuccess: async () => {
toast.success("Time entry updated");
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.timeEntries.getById.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (e) => toast.error(e.message),
});
const deleteEntry = api.timeEntries.delete.useMutation({
onSuccess: async () => {
toast.success("Time entry deleted");
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (e) => toast.error(e.message),
});
function handleSave() {
const start = new Date(startedAt);
const end = endedAt ? new Date(endedAt) : undefined;
if (Number.isNaN(start.getTime()) || (end && Number.isNaN(end.getTime()))) {
toast.error("Invalid start or end time");
return;
}
if (end && end <= start) {
toast.error("End time must be after start time");
return;
}
updateEntry.mutate({
id: entryId,
description,
clientId: clientId || "",
invoiceId: invoiceId || "",
rate,
startedAt: start,
endedAt: end,
hours: hoursPreview ?? undefined,
});
}
return (
<>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="entry-description">Description</Label>
<Input
id="entry-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Client</Label>
<Select
value={clientId || "__none__"}
onValueChange={(v) => {
setClientId(v === "__none__" ? "" : v);
setInvoiceId("");
}}
>
<SelectTrigger>
<SelectValue placeholder="No client" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No client</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Invoice</Label>
<Select
value={invoiceId || "__none__"}
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
disabled={!clientId}
>
<SelectTrigger>
<SelectValue placeholder="Not on invoice" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Not on invoice</SelectItem>
{billableInvoices?.map((inv) => (
<SelectItem key={inv.id} value={inv.id}>
{invoiceLabel(inv)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Hourly rate</Label>
<NumberInput value={rate} onChange={setRate} min={0} step={0.01} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="entry-start">Started</Label>
<Input
id="entry-start"
type="datetime-local"
value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="entry-end">Ended</Label>
<Input
id="entry-end"
type="datetime-local"
value={endedAt}
onChange={(e) => setEndedAt(e.target.value)}
/>
</div>
</div>
{hoursPreview != null ? (
<p className="text-muted-foreground text-sm">
Duration: {hoursPreview.toFixed(2)}h
{rate > 0 ? ` · $${(hoursPreview * rate).toFixed(2)}` : ""}
</p>
) : null}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button
type="button"
variant="destructive"
disabled={deleteEntry.isPending}
onClick={() => deleteEntry.mutate({ id: entryId })}
>
Delete
</Button>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="button" onClick={handleSave} disabled={updateEntry.isPending}>
Save
</Button>
</div>
</DialogFooter>
</>
);
}
export function TimeEntryEditDialog({
entryId,
open,
onOpenChange,
}: TimeEntryEditDialogProps) {
const entryQuery = api.timeEntries.getById.useQuery(
{ id: entryId ?? "" },
{ enabled: Boolean(entryId) && open },
);
const { data: clients = [] } = api.clients.getAll.useQuery(undefined, { enabled: open });
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit time entry</DialogTitle>
</DialogHeader>
{entryQuery.isLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : entryQuery.data && entryId ? (
<TimeEntryEditForm
key={entryQuery.data.id}
entry={entryQuery.data}
entryId={entryId}
clients={clients}
onClose={() => onOpenChange(false)}
/>
) : (
<p className="text-muted-foreground text-sm">Time entry not found.</p>
)}
</DialogContent>
</Dialog>
);
}
+26 -2
View File
@@ -6,11 +6,13 @@ import { entryHref, invoiceLabel, type TimeEntryListItem } from "~/lib/time-entr
export function TimeEntryRow({
entry,
isLast,
onEdit,
}: {
entry: TimeEntryListItem;
isLast?: boolean;
onEdit?: (entry: TimeEntryListItem) => void;
}) {
const href = entryHref(entry);
const href = onEdit ? null : entryHref(entry);
const rowClassName = cn(
"flex items-start justify-between gap-4 py-3",
!isLast && "border-border border-b",
@@ -50,6 +52,21 @@ export function TimeEntryRow({
);
}
if (onEdit) {
return (
<button
type="button"
onClick={() => onEdit(entry)}
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 text-left transition-colors hover:rounded-md hover:bg-muted/60",
)}
>
{content}
</button>
);
}
return (
<div className={rowClassName}>
{content}
@@ -57,7 +74,13 @@ export function TimeEntryRow({
);
}
export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
export function TimeEntryList({
entries,
onEdit,
}: {
entries: TimeEntryListItem[];
onEdit?: (entry: TimeEntryListItem) => void;
}) {
const completed = entries.filter((e) => e.endedAt);
if (completed.length === 0) return null;
@@ -69,6 +92,7 @@ export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
key={entry.id}
entry={entry}
isLast={index === completed.length - 1}
onEdit={onEdit}
/>
))}
</>
+6 -1
View File
@@ -34,13 +34,17 @@ export const env = createEnv({
.default("development"),
DB_DISABLE_SSL: optionalEnvBoolean(),
DISABLE_SIGNUPS: optionalEnvBoolean().default(true),
CRON_SECRET: z.string().optional(),
// Optional — only gates POST /api/cron/generate-recurring; the route itself
// returns a clean error when unset, so deployments that don't use recurring
// invoices don't need to configure it.
CRON_SECRET: z.string().min(32).optional(),
// S3-compatible object storage (optional — local .data/receipts/ fallback when unset)
S3_ENDPOINT: z.string().url().optional(),
S3_BUCKET: z.string().optional(),
S3_ACCESS_KEY: z.string().optional(),
S3_SECRET_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
S3_FORCE_PATH_STYLE: optionalEnvBoolean(),
// SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().optional(),
AUTHENTIK_CLIENT_ID: z.string().optional(),
@@ -87,6 +91,7 @@ export const env = createEnv({
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
S3_REGION: process.env.S3_REGION,
S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
+65 -4
View File
@@ -1,15 +1,76 @@
import { headers as nextHeaders } from "next/headers";
import { auth } from "~/lib/auth";
const MOBILE_AUTH_COOKIE_HEADER = "x-beenvoice-auth-cookie";
const MOBILE_SESSION_TOKEN_HEADER = "x-beenvoice-session-token";
const MAX_AUTH_COOKIE_HEADER_LENGTH = 16 * 1024;
const MAX_SESSION_TOKEN_LENGTH = 255;
const SESSION_TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]+$/;
function looksLikeSessionCookie(cookie: string): boolean {
return cookie.split(";").some((part) => {
const name =
part
.trim()
.split("=", 1)[0]
?.replace(/^__Secure-/, "") ?? "";
return (
name === "better-auth.session_token" ||
name === "better-auth.session_data" ||
name.startsWith("better-auth.session_token.") ||
name.startsWith("better-auth.session_data.") ||
name.endsWith(".session_token") ||
name.endsWith(".session_data") ||
name.includes(".session_token.") ||
name.includes(".session_data.")
);
});
}
export function headersWithAuthCookieFallback(headers: Headers): Headers {
const mobileCookie = headers.get(MOBILE_AUTH_COOKIE_HEADER)?.trim();
if (
mobileCookie &&
mobileCookie.length <= MAX_AUTH_COOKIE_HEADER_LENGTH &&
looksLikeSessionCookie(mobileCookie)
) {
const nextHeaders = new Headers(headers);
nextHeaders.set("cookie", mobileCookie);
return nextHeaders;
}
if (headers.get("cookie")?.trim()) return headers;
const sessionToken = headers.get(MOBILE_SESSION_TOKEN_HEADER)?.trim();
if (
sessionToken &&
sessionToken.length <= MAX_SESSION_TOKEN_LENGTH &&
SESSION_TOKEN_PATTERN.test(sessionToken)
) {
const nextHeaders = new Headers(headers);
nextHeaders.set(
"cookie",
[
`better-auth.session_token=${sessionToken}`,
`__Secure-better-auth.session_token=${sessionToken}`,
].join("; "),
);
return nextHeaders;
}
return headers;
}
export function hasSessionCookie(headers: Headers): boolean {
const cookie = headers.get("cookie") ?? "";
return (
cookie.includes("better-auth.session_token=") ||
cookie.includes("__Secure-better-auth.session_token=")
);
if (!cookie.trim()) return false;
return looksLikeSessionCookie(cookie);
}
export async function getOptionalServerSession(headers: Headers) {
headers = headersWithAuthCookieFallback(headers);
if (!hasSessionCookie(headers)) {
return null;
}
+38 -20
View File
@@ -5,6 +5,7 @@ import { nextCookies } from "better-auth/next-js";
import { genericOAuth } from "better-auth/plugins";
import { env } from "~/env";
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
import { sendPasswordResetEmail } from "~/lib/password-reset";
import { db } from "~/server/db";
import * as schema from "~/server/db/schema";
@@ -26,7 +27,9 @@ const staticTrustedOrigins = [
...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []),
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
"beenvoice://",
"exp://",
...(env.NODE_ENV === "development"
? ["exp://", "http://localhost:3000", "http://127.0.0.1:3000"]
: []),
...(authentikOrigin ? [authentikOrigin] : []),
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
];
@@ -37,6 +40,29 @@ export const auth = betterAuth({
advanced: {
trustedProxyHeaders: true,
},
rateLimit: {
enabled: true,
window: 60,
max: 100,
customRules: {
"/sign-in/email": {
window: 60,
max: 10,
},
"/sign-up/email": {
window: 60 * 60,
max: 5,
},
"/request-password-reset": {
window: 60 * 60,
max: 5,
},
"/reset-password": {
window: 60,
max: 10,
},
},
},
experimental: {
joins: true,
},
@@ -61,25 +87,7 @@ export const auth = betterAuth({
},
},
},
trustedOrigins: async (request) => {
const origins = [...staticTrustedOrigins];
if (!request) return origins;
const origin = request.headers.get("origin");
if (origin) origins.push(origin);
const forwardedHost = request.headers.get("x-forwarded-host");
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "https";
if (forwardedHost) {
for (const host of forwardedHost.split(",")) {
const trimmed = host.trim();
if (trimmed) origins.push(`${forwardedProto}://${trimmed}`);
}
}
return origins;
},
trustedOrigins: staticTrustedOrigins,
...(authentikEnabled && {
accountLinking: {
enabled: true,
@@ -89,6 +97,16 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
disableSignUp: signupsDisabled,
minPasswordLength: 8,
resetPasswordTokenExpiresIn: 60 * 60,
revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, token }) => {
await sendPasswordResetEmail({
userEmail: user.email,
userName: user.name ?? undefined,
resetToken: token,
});
},
password: {
hash: async (password) => {
const bcrypt = await import("bcryptjs");
+77 -22
View File
@@ -21,13 +21,62 @@ type S3Module = typeof import("@aws-sdk/client-s3");
let s3ModulePromise: Promise<S3Module> | null = null;
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
let s3DnsHintLogged = false;
let s3BareGarageHintLogged = false;
function shouldForcePathStyle(): boolean {
const override = process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase();
if (override === "true" || override === "1") return true;
if (override === "false" || override === "0") return false;
return Boolean(process.env.S3_ENDPOINT);
}
function logBareGarageEndpointHint(): void {
if (s3BareGarageHintLogged || process.env.NODE_ENV !== "production") return;
const endpoint = process.env.S3_ENDPOINT;
if (!endpoint) return;
try {
const { hostname } = new URL(endpoint);
if (hostname !== "garage") return;
s3BareGarageHintLogged = true;
console.warn(
"[object-storage] S3_ENDPOINT hostname is bare 'garage'. " +
"That only resolves inside a single Docker Compose stack. " +
"Coolify Application + separate Garage compose: set S3_ENDPOINT to " +
"SERVICE_URL_GARAGE_3900 (public domain) or http://garage-<resource-uuid>:3900. " +
"See docs/COOLIFY.md.",
);
} catch {
// Invalid URL — env validation or S3 client will surface it.
}
}
function logS3DnsHint(error: unknown): void {
if (s3DnsHintLogged) return;
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOTFOUND" && code !== "EAI_AGAIN") return;
s3DnsHintLogged = true;
const endpoint = process.env.S3_ENDPOINT ?? "(AWS default)";
console.error(
`[object-storage] S3 DNS failed (${code}) for endpoint ${endpoint}. ` +
"Separate Coolify stacks cannot resolve bare 'garage' — use the internal hostname from the Garage resource UI and enable Connect to Predefined Network on the app. See docs/COOLIFY.md.",
);
}
async function withS3Diagnostics<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
logS3DnsHint(error);
throw error;
}
}
async function getS3() {
if (!s3ModulePromise) {
s3ModulePromise = import("@aws-sdk/client-s3");
}
s3ModulePromise ??= import("@aws-sdk/client-s3");
const mod = await s3ModulePromise;
if (!s3Client) {
logBareGarageEndpointHint();
s3Client = new mod.S3Client({
region: process.env.S3_REGION ?? "us-east-1",
endpoint: process.env.S3_ENDPOINT,
@@ -35,8 +84,8 @@ async function getS3() {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
// Required for MinIO and most S3-compatible endpoints.
forcePathStyle: Boolean(process.env.S3_ENDPOINT),
// Required for Garage and most S3-compatible endpoints (including HTTPS proxies).
forcePathStyle: shouldForcePathStyle(),
});
}
return { client: s3Client, ...mod };
@@ -53,13 +102,15 @@ export async function putObject(
): Promise<void> {
if (isS3Configured()) {
const { client, PutObjectCommand } = await getS3();
await client.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: body,
ContentType: contentType,
}),
await withS3Diagnostics(() =>
client.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: body,
ContentType: contentType,
}),
),
);
return;
}
@@ -72,11 +123,13 @@ export async function putObject(
export async function getObject(key: string): Promise<Buffer> {
if (isS3Configured()) {
const { client, GetObjectCommand } = await getS3();
const response = await client.send(
new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}),
const response = await withS3Diagnostics(() =>
client.send(
new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}),
),
);
const bytes = await response.Body?.transformToByteArray();
if (!bytes) {
@@ -91,11 +144,13 @@ export async function getObject(key: string): Promise<Buffer> {
export async function deleteObject(key: string): Promise<void> {
if (isS3Configured()) {
const { client, DeleteObjectCommand } = await getS3();
await client.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}),
await withS3Diagnostics(() =>
client.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}),
),
);
return;
}
+52 -36
View File
@@ -1,10 +1,13 @@
import crypto from "crypto";
import { eq } from "drizzle-orm";
import { Resend } from "resend";
import { env } from "~/env";
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url";
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
import {
createPasswordResetToken,
hashPasswordResetToken,
} from "~/lib/reset-token";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
@@ -14,6 +17,45 @@ export type PasswordResetResult = {
userEmail?: string;
};
export async function sendPasswordResetEmail(input: {
userEmail: string;
userName?: string;
resetToken: string;
}): Promise<PasswordResetResult> {
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return { success: true, emailSent: false, userEmail: input.userEmail };
}
try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${input.resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: input.userEmail,
userName: input.userName,
resetToken: input.resetToken,
resetUrl,
expiryHours: 1,
});
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
await resend.emails.send({
from: `beenvoice <noreply@${fromDomain}>`,
to: input.userEmail,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text,
});
return { success: true, emailSent: true, userEmail: input.userEmail };
} catch (emailError) {
console.error("Failed to send password reset email:", emailError);
return { success: true, emailSent: false, userEmail: input.userEmail };
}
}
export async function sendPasswordResetForUser(
userId: string,
): Promise<PasswordResetResult> {
@@ -26,44 +68,18 @@ export async function sendPasswordResetForUser(
return { success: false, emailSent: false };
}
const resetToken = crypto.randomBytes(32).toString("hex");
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
const resetToken = createPasswordResetToken();
const resetTokenHash = hashPasswordResetToken(resetToken);
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
await db
.update(users)
.set({ resetToken, resetTokenExpiry })
.set({ resetToken: resetTokenHash, resetTokenExpiry })
.where(eq(users.id, user.id));
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return { success: true, emailSent: false, userEmail: user.email };
}
try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: user.email,
userName: user.name ?? undefined,
resetToken,
resetUrl,
expiryHours: 24,
});
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
await resend.emails.send({
from: `beenvoice <noreply@${fromDomain}>`,
to: user.email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text,
});
return { success: true, emailSent: true, userEmail: user.email };
} catch (emailError) {
console.error("Failed to send password reset email:", emailError);
return { success: true, emailSent: false, userEmail: user.email };
}
return sendPasswordResetEmail({
userEmail: user.email,
userName: user.name ?? undefined,
resetToken,
});
}
+72
View File
@@ -0,0 +1,72 @@
import { createHash } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";
type RateLimitRule = {
windowMs: number;
max: number;
};
type RateLimitRecord = {
count: number;
resetAt: number;
};
const buckets = new Map<string, RateLimitRecord>();
function clientIp(request: NextRequest) {
return (
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-real-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
"unknown"
);
}
export function hashRateLimitPart(value: string) {
return createHash("sha256").update(value).digest("hex");
}
export function rateLimitKey(request: NextRequest, scope: string, subject?: string) {
const parts = [scope, clientIp(request)];
if (subject) parts.push(hashRateLimitPart(subject.toLowerCase().trim()));
return parts.join(":");
}
function retryAfterSeconds(resetAt: number) {
return Math.max(1, Math.ceil((resetAt - Date.now()) / 1000));
}
export function checkRateLimit(key: string, rule: RateLimitRule) {
const now = Date.now();
const existing = buckets.get(key);
if (!existing || existing.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + rule.windowMs });
return { allowed: true, retryAfter: 0 };
}
existing.count += 1;
if (existing.count <= rule.max) {
return { allowed: true, retryAfter: 0 };
}
return { allowed: false, retryAfter: retryAfterSeconds(existing.resetAt) };
}
export function rateLimitResponse(retryAfter: number) {
return NextResponse.json(
{ error: "Too many attempts. Please wait and try again." },
{
status: 429,
headers: {
"Retry-After": String(retryAfter),
"X-RateLimit-Retry-After": String(retryAfter),
},
},
);
}
export function requireRateLimit(key: string, rule: RateLimitRule) {
const result = checkRateLimit(key, rule);
return result.allowed ? null : rateLimitResponse(result.retryAfter);
}
+63
View File
@@ -0,0 +1,63 @@
export type ReceiptParseResult = {
amount: number | null;
date: Date | null;
vendor: string | null;
rawLines: string[];
};
const AMOUNT_PATTERNS = [
/(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
/\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i,
/(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i,
];
const DATE_PATTERNS = [
/(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/,
/(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/,
];
function parseAmount(text: string): number | null {
for (const pattern of AMOUNT_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const value = Number(match[1].replace(/,/g, ""));
if (Number.isFinite(value) && value > 0) return value;
}
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
.map((m) => Number(m[1]!.replace(/,/g, "")))
.filter((n) => Number.isFinite(n) && n > 0);
return amounts.length > 0 ? Math.max(...amounts) : null;
}
function parseDate(text: string): Date | null {
for (const pattern of DATE_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const parsed = new Date(match[1]);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return null;
}
function parseVendor(lines: string[]): string | null {
const candidate = lines.find((line) => line.trim().length >= 3);
return candidate?.trim().slice(0, 120) ?? null;
}
/** Heuristic receipt field extraction from OCR or pasted text. */
export function parseReceiptText(text: string): ReceiptParseResult {
const normalized = text.replace(/\r/g, "\n").trim();
const rawLines = normalized
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return {
amount: parseAmount(normalized),
date: parseDate(normalized),
vendor: parseVendor(rawLines),
rawLines,
};
}
+9
View File
@@ -0,0 +1,9 @@
import { createHash, randomBytes } from "node:crypto";
export function createPasswordResetToken() {
return randomBytes(32).toString("hex");
}
export function hashPasswordResetToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
+23
View File
@@ -0,0 +1,23 @@
const FALLBACK_CALLBACK_PATH = "/dashboard";
export function safeCallbackPath(value: string | null | undefined) {
if (!value) return FALLBACK_CALLBACK_PATH;
const trimmed = value.trim();
if (
!trimmed.startsWith("/") ||
trimmed.startsWith("//") ||
trimmed.includes("\\") ||
/[\u0000-\u001f\u007f]/.test(trimmed)
) {
return FALLBACK_CALLBACK_PATH;
}
try {
const url = new URL(trimmed, "https://beenvoice.local");
if (url.origin !== "https://beenvoice.local") return FALLBACK_CALLBACK_PATH;
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return FALLBACK_CALLBACK_PATH;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { and, eq, ne } from "drizzle-orm";
import { db } from "~/server/db";
import { sessions } from "~/server/db/schema";
export async function revokeUserSessions(userId: string, exceptToken?: string | null) {
const condition = exceptToken
? and(eq(sessions.userId, userId), ne(sessions.token, exceptToken))
: eq(sessions.userId, userId);
await db.delete(sessions).where(condition);
}
+16 -7
View File
@@ -1,6 +1,17 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { isPublicRoute } from "~/lib/public-routes";
import { safeCallbackPath } from "~/lib/safe-callback-url";
function hasBetterAuthSessionCookie(request: NextRequest) {
return request.cookies.getAll().some(({ name }) => {
const cookieName = name.replace(/^__Secure-/, "");
return (
cookieName === "better-auth.session_token" ||
cookieName.startsWith("better-auth.session_token.")
);
});
}
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
@@ -18,15 +29,13 @@ export function proxy(request: NextRequest) {
return NextResponse.next();
}
// Check for session token in cookies (Better Auth cookie names)
const sessionToken =
request.cookies.get("better-auth.session_token")?.value ??
request.cookies.get("__Secure-better-auth.session_token")?.value;
// If no session token, redirect to sign-in
if (!sessionToken) {
if (!hasBetterAuthSessionCookie(request)) {
const signInUrl = new URL("/auth/signin", request.url);
signInUrl.searchParams.set("callbackUrl", request.url);
signInUrl.searchParams.set(
"callbackUrl",
safeCallbackPath(`${request.nextUrl.pathname}${request.nextUrl.search}`),
);
return NextResponse.redirect(signInUrl);
}
@@ -0,0 +1,202 @@
import { and, eq } from "drizzle-orm";
import type { db } from "~/server/db";
import { invoiceItems, invoices, timeEntries } from "~/server/db/schema";
import { resolveBillingDescription } from "~/lib/time-clock";
type Db = typeof db;
function recalculateInvoiceTotal(
items: { amount: number }[],
taxRate: number,
): number {
const subtotal = items.reduce((sum, item) => sum + item.amount, 0);
return subtotal + (subtotal * taxRate) / 100;
}
export async function findLinkedInvoiceItem(database: Db, timeEntryId: string) {
return database.query.invoiceItems.findFirst({
where: eq(invoiceItems.timeEntryId, timeEntryId),
with: {
invoice: {
columns: { id: true, taxRate: true, status: true, createdById: true },
},
},
});
}
export async function insertInvoiceLineForTimeEntry(
database: Db,
input: {
invoice: {
id: string;
invoiceNumber: string;
invoicePrefix: string | null;
taxRate: number;
items: { amount: number; position: number }[];
};
entryId: string;
description: string;
hours: number;
rate: number;
date: Date;
},
) {
const amount = input.hours * input.rate;
const maxPosition = input.invoice.items.reduce(
(m, item) => Math.max(m, item.position),
-1,
);
await database.insert(invoiceItems).values({
invoiceId: input.invoice.id,
date: input.date,
description: input.description,
hours: input.hours,
rate: input.rate,
amount,
position: maxPosition + 1,
timeEntryId: input.entryId,
});
const subtotal =
input.invoice.items.reduce((s, i) => s + i.amount, 0) + amount;
const newTotal = subtotal + (subtotal * input.invoice.taxRate) / 100;
await database
.update(invoices)
.set({ totalAmount: newTotal, updatedAt: new Date() })
.where(eq(invoices.id, input.invoice.id));
await database
.update(timeEntries)
.set({ invoiceId: input.invoice.id, updatedAt: new Date() })
.where(eq(timeEntries.id, input.entryId));
return {
id: input.invoice.id,
invoiceNumber: input.invoice.invoiceNumber,
invoicePrefix: input.invoice.invoicePrefix ?? "#",
};
}
export async function syncLinkedInvoiceItem(
database: Db,
entry: {
id: string;
description: string | null;
hours: number | null;
rate: number | null;
startedAt: Date;
endedAt: Date | null;
invoiceId: string | null;
},
) {
const linked = await findLinkedInvoiceItem(database, entry.id);
if (!linked?.invoice) return;
if (linked.invoice.status !== "draft") return;
const hours =
entry.hours ??
(entry.endedAt
? Math.max(
0,
(entry.endedAt.getTime() - entry.startedAt.getTime()) / 3_600_000,
)
: null);
if (hours == null || hours <= 0) return;
const rate = entry.rate ?? 0;
const amount = hours * rate;
const description = resolveBillingDescription(entry.description ?? "");
await database
.update(invoiceItems)
.set({
description,
hours,
rate,
amount,
date: entry.endedAt ?? entry.startedAt,
})
.where(eq(invoiceItems.id, linked.id));
const siblings = await database.query.invoiceItems.findMany({
where: eq(invoiceItems.invoiceId, linked.invoiceId),
columns: { amount: true },
});
await database
.update(invoices)
.set({
totalAmount: recalculateInvoiceTotal(siblings, linked.invoice.taxRate),
updatedAt: new Date(),
})
.where(eq(invoices.id, linked.invoiceId));
}
export async function removeLinkedInvoiceItem(database: Db, timeEntryId: string) {
const linked = await findLinkedInvoiceItem(database, timeEntryId);
if (!linked?.invoice) return;
await database.delete(invoiceItems).where(eq(invoiceItems.id, linked.id));
const siblings = await database.query.invoiceItems.findMany({
where: eq(invoiceItems.invoiceId, linked.invoiceId),
columns: { amount: true },
});
await database
.update(invoices)
.set({
totalAmount: recalculateInvoiceTotal(siblings, linked.invoice.taxRate),
updatedAt: new Date(),
})
.where(eq(invoices.id, linked.invoiceId));
}
export async function relinkTimeEntryToInvoice(
database: Db,
userId: string,
entry: {
id: string;
description: string | null;
hours: number | null;
rate: number | null;
startedAt: Date;
endedAt: Date | null;
clientId: string | null;
},
invoiceId: string | null,
) {
await removeLinkedInvoiceItem(database, entry.id);
if (!invoiceId || !entry.endedAt || !entry.hours || entry.hours <= 0) {
await database
.update(timeEntries)
.set({ invoiceId: invoiceId ?? null, updatedAt: new Date() })
.where(eq(timeEntries.id, entry.id));
return null;
}
const invoice = await database.query.invoices.findFirst({
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
),
with: { items: true },
});
if (!invoice) return null;
return insertInvoiceLineForTimeEntry(database, {
invoice,
entryId: entry.id,
description: resolveBillingDescription(entry.description ?? ""),
hours: entry.hours,
rate: entry.rate ?? 0,
date: entry.endedAt,
});
}
+5 -22
View File
@@ -7,22 +7,11 @@ import {
getApiKeyDisplayPrefix,
hashApiKey,
} from "~/server/api/api-keys";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
import { apiKeys } from "~/server/db/schema";
function requireSessionAuth(ctx: { authSource: "session" | "api-key" | "none" }) {
if (ctx.authSource !== "session") {
throw new TRPCError({
code: "FORBIDDEN",
message: "API keys can only be managed from an authenticated session",
});
}
}
export const apiKeysRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
requireSessionAuth(ctx);
list: sessionProcedure.query(async ({ ctx }) => {
return ctx.db.query.apiKeys.findMany({
where: eq(apiKeys.userId, ctx.session.user.id),
columns: {
@@ -39,7 +28,7 @@ export const apiKeysRouter = createTRPCRouter({
});
}),
create: protectedProcedure
create: sessionProcedure
.input(
z.object({
name: z.string().trim().min(1).max(100),
@@ -47,8 +36,6 @@ export const apiKeysRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
requireSessionAuth(ctx);
if (input.expiresAt && input.expiresAt <= new Date()) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -84,11 +71,9 @@ export const apiKeysRouter = createTRPCRouter({
return { ...apiKey, key };
}),
revoke: protectedProcedure
revoke: sessionProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requireSessionAuth(ctx);
const now = new Date();
const [apiKey] = await ctx.db
.update(apiKeys)
@@ -108,9 +93,7 @@ export const apiKeysRouter = createTRPCRouter({
return { success: true };
}),
revokeAll: protectedProcedure.mutation(async ({ ctx }) => {
requireSessionAuth(ctx);
revokeAll: sessionProcedure.mutation(async ({ ctx }) => {
const now = new Date();
await ctx.db
.update(apiKeys)
+30 -1
View File
@@ -1,4 +1,4 @@
import { and, desc, eq } from "drizzle-orm";
import { and, desc, eq, gte, lt } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { clients, invoices } from "~/server/db/schema";
@@ -172,6 +172,7 @@ export const dashboardRouter = createTRPCRouter({
userInvoices,
userClientsCount,
recentInvoices,
monthInvoices,
currentDraft,
] = await Promise.all([
ctx.db.query.invoices.findMany({
@@ -199,6 +200,33 @@ export const dashboardRouter = createTRPCRouter({
},
},
}),
ctx.db.query.invoices.findMany({
where: and(
eq(invoices.createdById, userId),
gte(invoices.issueDate, new Date(now.getFullYear(), now.getMonth(), 1)),
lt(invoices.issueDate, new Date(now.getFullYear(), now.getMonth() + 1, 1)),
),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
columns: {
id: true,
invoicePrefix: true,
invoiceNumber: true,
totalAmount: true,
status: true,
dueDate: true,
issueDate: true,
currency: true,
},
with: {
client: {
columns: { name: true },
},
},
}),
ctx.db.query.invoices.findFirst({
where: and(
eq(invoices.createdById, userId),
@@ -227,6 +255,7 @@ export const dashboardRouter = createTRPCRouter({
...metrics,
totalClients: userClientsCount,
recentInvoices,
monthInvoices,
currentDraft: currentDraft
? {
id: currentDraft.id,
+2 -2
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { Resend } from "resend";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { createTRPCRouter, sessionProcedure } from "~/server/api/trpc";
import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm";
import { env } from "~/env";
@@ -36,7 +36,7 @@ function normalizeEmailNoteHtml(value: string) {
}
export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure
sendInvoice: sessionProcedure
.input(
z.object({
invoiceId: z.string(),
+68 -5
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { eq, and, desc } from "drizzle-orm";
import { eq, and, desc, inArray } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import {
expenses,
@@ -19,6 +19,7 @@ import {
putObject,
RECEIPT_MAX_BYTES,
} from "~/lib/object-storage";
import { parseReceiptText } from "~/lib/receipt-parse";
export { EXPENSE_CATEGORIES };
@@ -132,16 +133,67 @@ export const expensesRouter = createTRPCRouter({
conditions.push(eq(expenses.businessId, input.businessId));
}
return await ctx.db.query.expenses.findMany({
const rows = await ctx.db.query.expenses.findMany({
where: and(...conditions),
with: {
client: true,
business: true,
invoice: true,
receipts: true,
},
orderBy: [desc(expenses.date)],
});
const expenseIds = rows.map((e) => e.id);
if (expenseIds.length === 0) return [];
const receiptMeta = await ctx.db
.select({
expenseId: expenseReceipts.expenseId,
id: expenseReceipts.id,
mimeType: expenseReceipts.mimeType,
originalFilename: expenseReceipts.originalFilename,
createdAt: expenseReceipts.createdAt,
})
.from(expenseReceipts)
.where(inArray(expenseReceipts.expenseId, expenseIds))
.orderBy(desc(expenseReceipts.createdAt));
const receiptStats = new Map<
string,
{
receiptCount: number;
receiptPreview: {
id: string;
mimeType: string;
originalFilename: string;
} | null;
}
>();
for (const receipt of receiptMeta) {
const existing = receiptStats.get(receipt.expenseId);
if (existing) {
existing.receiptCount += 1;
} else {
receiptStats.set(receipt.expenseId, {
receiptCount: 1,
receiptPreview: {
id: receipt.id,
mimeType: receipt.mimeType,
originalFilename: receipt.originalFilename,
},
});
}
}
return rows.map((expense) => {
const stats = receiptStats.get(expense.id);
return {
...expense,
receiptCount: stats?.receiptCount ?? 0,
receiptPreview: stats?.receiptPreview ?? null,
};
});
}),
getById: protectedProcedure
@@ -380,8 +432,7 @@ export const expensesRouter = createTRPCRouter({
});
if (
!receipt ||
receipt.expense.createdById !== ctx.session.user.id
receipt?.expense.createdById !== ctx.session.user.id
) {
throw new TRPCError({
code: "NOT_FOUND",
@@ -396,4 +447,16 @@ export const expensesRouter = createTRPCRouter({
return { success: true };
}),
suggestFromReceiptText: protectedProcedure
.input(z.object({ text: z.string().min(1).max(20_000) }))
.mutation(({ input }) => {
const parsed = parseReceiptText(input.text);
return {
amount: parsed.amount,
date: parsed.date,
description: parsed.vendor,
rawLines: parsed.rawLines,
};
}),
});
+10 -5
View File
@@ -1,6 +1,11 @@
import { z } from "zod";
import { and, desc, eq, inArray } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
sessionProcedure,
} from "../trpc";
import {
invoices,
invoiceItems,
@@ -754,7 +759,7 @@ export const invoicesRouter = createTRPCRouter({
return { success: true, deleted: ownedIds.length };
}),
bulkImport: protectedProcedure
bulkImport: sessionProcedure
.input(bulkImportSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
@@ -998,7 +1003,7 @@ export const invoicesRouter = createTRPCRouter({
// ── Public token (shareable link) ──────────────────────────────────────────
generatePublicToken: protectedProcedure
generatePublicToken: sessionProcedure
.input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() }))
.mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({
@@ -1018,7 +1023,7 @@ export const invoicesRouter = createTRPCRouter({
return { token, expiresAt };
}),
revokePublicToken: protectedProcedure
revokePublicToken: sessionProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({
@@ -1060,7 +1065,7 @@ export const invoicesRouter = createTRPCRouter({
// ── Send reminder ──────────────────────────────────────────────────────────
sendReminder: protectedProcedure
sendReminder: sessionProcedure
.input(z.object({ id: z.string(), customMessage: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
const invoice = await ctx.db.query.invoices.findFirst({
+8 -4
View File
@@ -7,6 +7,7 @@ import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
sessionProcedure,
} from "~/server/api/trpc";
import { requireAdmin } from "~/server/api/require-admin";
import {
@@ -32,6 +33,7 @@ import {
pdfTemplateSchema,
type ColorMode,
} from "~/lib/branding";
import { revokeUserSessions } from "~/lib/session-security";
function resolveBusinessId(
refs: { businessName?: string; businessNickname?: string },
@@ -512,7 +514,7 @@ export const settingsRouter = createTRPCRouter({
}),
// Change user password
changePassword: protectedProcedure
changePassword: sessionProcedure
.input(
z
.object({
@@ -595,11 +597,13 @@ export const settingsRouter = createTRPCRouter({
}
});
await revokeUserSessions(userId, ctx.session.session?.token);
return { success: true };
}),
// Export user data (backup)
exportData: protectedProcedure.query(async ({ ctx }) => {
exportData: sessionProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
const user = await ctx.db.query.users.findFirst({
@@ -855,7 +859,7 @@ export const settingsRouter = createTRPCRouter({
}),
// Import user data (restore)
importData: protectedProcedure
importData: sessionProcedure
.input(BackupDataSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
@@ -1168,7 +1172,7 @@ export const settingsRouter = createTRPCRouter({
}),
// Delete all user data (for account deletion)
deleteAllData: protectedProcedure
deleteAllData: sessionProcedure
.input(
z.object({
confirmText: z.string().refine((val) => val === "DELETE ALL DATA", {
+67 -34
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { timeEntries, clients, invoices, invoiceItems, businesses } from "~/server/db/schema";
import { timeEntries, clients, invoices, businesses } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import type { db } from "~/server/db";
import {
@@ -10,6 +10,12 @@ import {
type ClockOutOutcome,
} from "~/lib/time-clock";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
import {
insertInvoiceLineForTimeEntry,
relinkTimeEntryToInvoice,
removeLinkedInvoiceItem,
syncLinkedInvoiceItem,
} from "~/server/api/lib/time-entry-invoice-sync";
type Db = typeof db;
@@ -55,37 +61,14 @@ async function addEntryToInvoice(
rate: number,
date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
const amount = hours * rate;
const maxPosition = invoice.items.reduce((m, item) => Math.max(m, item.position), -1);
await database.insert(invoiceItems).values({
invoiceId: invoice.id,
date,
return insertInvoiceLineForTimeEntry(database, {
invoice,
entryId,
description,
hours,
rate,
amount,
position: maxPosition + 1,
date,
});
const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0) + amount;
const newTotal = subtotal + (subtotal * invoice.taxRate) / 100;
await database
.update(invoices)
.set({ totalAmount: newTotal, updatedAt: new Date() })
.where(eq(invoices.id, invoice.id));
await database
.update(timeEntries)
.set({ invoiceId: invoice.id, updatedAt: new Date() })
.where(eq(timeEntries.id, entryId));
return {
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix ?? "#",
};
}
async function findOrCreateDraftInvoice(
@@ -226,7 +209,10 @@ export const timeEntriesRouter = createTRPCRouter({
),
with: {
client: true,
invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } },
invoice: {
columns: { id: true, invoiceNumber: true, invoicePrefix: true },
with: { business: { columns: { id: true, name: true } } },
},
},
});
return entry ?? null;
@@ -256,7 +242,7 @@ export const timeEntriesRouter = createTRPCRouter({
});
}
const clientId = input.clientId?.trim() ?? null;
const clientId = input.clientId?.trim() || null;
let clientRecord: { defaultHourlyRate: number | null } | null = null;
if (clientId) {
const found = await ctx.db.query.clients.findFirst({
@@ -338,6 +324,7 @@ export const timeEntriesRouter = createTRPCRouter({
clientId: z.string().optional().or(z.literal("")),
invoiceId: z.string().optional().or(z.literal("")),
rate: z.number().min(0).optional(),
startedAt: z.date().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -357,9 +344,20 @@ export const timeEntriesRouter = createTRPCRouter({
clientId?: string | null;
invoiceId?: string | null;
rate?: number | null;
startedAt?: Date;
updatedAt: Date;
} = { updatedAt: new Date() };
if (input.startedAt !== undefined) {
if (input.startedAt > new Date()) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Start time cannot be in the future",
});
}
updates.startedAt = input.startedAt;
}
if (input.description !== undefined) {
updates.description = input.description;
}
@@ -516,7 +514,7 @@ export const timeEntriesRouter = createTRPCRouter({
create: protectedProcedure
.input(createSchema)
.mutation(async ({ ctx, input }) => {
const clientId = input.clientId?.trim() ?? null;
const clientId = input.clientId?.trim() || null;
if (clientId) {
const client = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
@@ -563,9 +561,13 @@ export const timeEntriesRouter = createTRPCRouter({
}),
update: protectedProcedure
.input(updateSchema)
.input(
updateSchema.extend({
invoiceId: z.string().optional().or(z.literal("")),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const { id, invoiceId: nextInvoiceId, ...data } = input;
const existing = await ctx.db.query.timeEntries.findFirst({
where: and(
@@ -575,6 +577,13 @@ export const timeEntriesRouter = createTRPCRouter({
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
if (existing.endedAt == null) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Use updateRunning to edit the active timer",
});
}
const clientId =
data.clientId !== undefined ? data.clientId?.trim() || null : undefined;
@@ -585,16 +594,39 @@ export const timeEntriesRouter = createTRPCRouter({
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
}
let hours = data.hours;
const startedAt = data.startedAt ?? existing.startedAt;
const endedAt = data.endedAt ?? existing.endedAt;
if (endedAt && (data.startedAt !== undefined || data.endedAt !== undefined || data.hours === undefined)) {
hours = computeHours(startedAt, endedAt);
}
await ctx.db
.update(timeEntries)
.set({
...data,
clientId,
notes: data.notes?.trim() ?? null,
hours,
notes: data.notes?.trim() ?? undefined,
updatedAt: new Date(),
})
.where(eq(timeEntries.id, id));
const updated = await ctx.db.query.timeEntries.findFirst({
where: eq(timeEntries.id, id),
});
if (!updated) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" });
}
if (nextInvoiceId !== undefined) {
await relinkTimeEntryToInvoice(ctx.db, ctx.session.user.id, updated, nextInvoiceId.trim() || null);
} else {
await syncLinkedInvoiceItem(ctx.db, updated);
}
return { success: true };
}),
@@ -609,6 +641,7 @@ export const timeEntriesRouter = createTRPCRouter({
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
await removeLinkedInvoiceItem(ctx.db, input.id);
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
return { success: true };
}),
+45 -5
View File
@@ -12,7 +12,11 @@ import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "~/lib/auth";
import { hasSessionCookie } from "~/lib/auth-server";
import {
hasSessionCookie,
headersWithAuthCookieFallback,
} from "~/lib/auth-server";
import { checkRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db";
import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys";
@@ -29,7 +33,8 @@ import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys";
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
const bearerToken = getBearerToken(opts.headers);
const headers = headersWithAuthCookieFallback(opts.headers);
const bearerToken = getBearerToken(headers);
if (bearerToken) {
const apiKeyAuth = await getUserForApiKey(db, bearerToken);
@@ -44,23 +49,25 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
authSource: "api-key" as const,
apiKeyId: apiKeyAuth.apiKeyId,
...opts,
headers,
};
}
}
if (!hasSessionCookie(opts.headers)) {
if (!hasSessionCookie(headers)) {
return {
db,
session: null,
authSource: "none" as const,
apiKeyId: null,
...opts,
headers,
};
}
try {
const session = await auth.api.getSession({
headers: opts.headers,
headers,
});
return {
@@ -69,6 +76,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
authSource: session?.user ? ("session" as const) : ("none" as const),
apiKeyId: null,
...opts,
headers,
};
} catch (error) {
console.error("[tRPC] Failed to resolve session:", error);
@@ -79,6 +87,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
authSource: "none" as const,
apiKeyId: null,
...opts,
headers,
};
}
};
@@ -143,6 +152,24 @@ const timingMiddleware = t.middleware(async ({ next, path }) => {
return result;
});
const apiKeyRateLimitMiddleware = t.middleware(({ ctx, next }) => {
if (ctx.authSource === "api-key" && ctx.apiKeyId) {
const result = checkRateLimit(`trpc:api-key:${ctx.apiKeyId}`, {
windowMs: 60 * 1000,
max: 120,
});
if (!result.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "API key rate limit exceeded. Please try again later.",
});
}
}
return next();
});
/**
* Public (unauthenticated) procedure
*
@@ -150,7 +177,9 @@ const timingMiddleware = t.middleware(async ({ next, path }) => {
* guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in.
*/
export const publicProcedure = t.procedure.use(timingMiddleware);
export const publicProcedure = t.procedure
.use(timingMiddleware)
.use(apiKeyRateLimitMiddleware);
/**
* Protected (authenticated) procedure
@@ -173,3 +202,14 @@ export const protectedProcedure = t.procedure
},
});
});
export const sessionProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.authSource !== "session") {
throw new TRPCError({
code: "FORBIDDEN",
message: "This action requires an authenticated browser or app session",
});
}
return next();
});
+7
View File
@@ -432,6 +432,9 @@ export const invoiceItems = createTable(
rate: d.real().notNull(),
amount: d.real().notNull(),
position: d.integer().notNull().default(0), // NEW: position for ordering
timeEntryId: d
.varchar({ length: 255 })
.references(() => timeEntries.id, { onDelete: "set null" }),
createdAt: d
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
@@ -449,6 +452,10 @@ export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({
fields: [invoiceItems.invoiceId],
references: [invoices.id],
}),
timeEntry: one(timeEntries, {
fields: [invoiceItems.timeEntryId],
references: [timeEntries.id],
}),
}));
export const expenses = createTable(
+44 -34
View File
@@ -8,45 +8,55 @@ import { TRPCClientError } from "@trpc/client";
import { toast } from "sonner";
import SuperJSON from "superjson";
function isUnauthorized(error: unknown): boolean {
return (
error instanceof TRPCClientError &&
error.data != null &&
typeof error.data === "object" &&
"code" in error.data &&
(error.data as { code: string }).code === "UNAUTHORIZED"
);
}
function isRateLimited(error: unknown): boolean {
if (!(error instanceof TRPCClientError)) return false;
if (error.data != null && typeof error.data === "object" && "code" in error.data) {
if ((error.data as { code: string }).code === "TOO_MANY_REQUESTS") return true;
}
const message = error.message.toLowerCase();
return message.includes("too many") || message.includes("rate limit");
}
function handleQueryError(error: unknown) {
if (isRateLimited(error)) {
toast.error("Too many requests. Please wait a moment and try again.");
return;
}
if (isUnauthorized(error)) {
toast.error("Please sign in to continue");
if (typeof window !== "undefined") {
window.location.href = "/auth/signin";
}
}
}
export const createQueryClient = () =>
new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (
error instanceof TRPCClientError &&
error.data &&
typeof error.data === "object" &&
"code" in error.data &&
(error.data as { code: string }).code === "UNAUTHORIZED"
) {
toast.error("Please sign in to continue");
if (typeof window !== "undefined") {
window.location.href = "/auth/signin";
}
}
},
}),
mutationCache: new MutationCache({
onError: (error) => {
if (
error instanceof TRPCClientError &&
error.data &&
typeof error.data === "object" &&
"code" in error.data &&
(error.data as { code: string }).code === "UNAUTHORIZED"
) {
toast.error("Please sign in to continue");
if (typeof window !== "undefined") {
window.location.href = "/auth/signin";
}
}
},
}),
queryCache: new QueryCache({ onError: handleQueryError }),
mutationCache: new MutationCache({ onError: handleQueryError }),
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 30 * 1000,
retry: (failureCount, error) => {
if (isUnauthorized(error) || isRateLimited(error)) return false;
return failureCount < 1;
},
},
mutations: {
retry: (failureCount, error) => {
if (isRateLimited(error)) return false;
return failureCount < 1;
},
},
dehydrate: {
serializeData: SuperJSON.serialize,