Compare commits
15
Commits
b9a9b813d2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f3b1362a9 | ||
|
|
40624f6e6f | ||
|
|
d589b94580 | ||
|
|
da219b287e | ||
|
|
316edb4b3e | ||
|
|
8866335c50 | ||
|
|
fb731885de | ||
|
|
1605322bc9 | ||
|
|
ef826dffa9 | ||
|
|
d0c916d659 | ||
|
|
a66d05f24f | ||
|
|
122a3c64f5 | ||
|
|
31151e7f39 | ||
|
|
c267b3e1c8 | ||
|
|
5e6e51337a |
+23
-15
@@ -4,10 +4,10 @@
|
|||||||
#
|
#
|
||||||
# Quick start (local dev):
|
# Quick start (local dev):
|
||||||
# cp .env.example .env.local
|
# 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 db:push # or: bun run db:migrate
|
||||||
# bun run dev
|
# bun run dev
|
||||||
# MinIO console: http://localhost:9001 (minioadmin / minioadmin)
|
# Garage S3 API: http://localhost:3900
|
||||||
#
|
#
|
||||||
# Quick start (Docker app + Postgres):
|
# Quick start (Docker app + Postgres):
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
@@ -66,8 +66,7 @@ DB_DISABLE_SSL=true
|
|||||||
|
|
||||||
# Dev-only: host ports for `docker compose -f docker-compose.dev.yml`.
|
# Dev-only: host ports for `docker compose -f docker-compose.dev.yml`.
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
MINIO_API_PORT=9000
|
GARAGE_API_PORT=3900
|
||||||
MINIO_CONSOLE_PORT=9001
|
|
||||||
|
|
||||||
# Optional: if Next dev picks another port, you do not need to change URLs for
|
# 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.
|
# 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)
|
# Receipt storage — S3-compatible (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# When unset, receipt files are stored locally in .data/receipts/ (dev-friendly).
|
# When S3_BUCKET + S3_ACCESS_KEY + S3_SECRET_KEY are unset, receipts land in
|
||||||
# Works with AWS S3, MinIO, Cloudflare R2, etc.
|
# .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 — who can reach Garage?
|
||||||
S3_ENDPOINT=http://localhost:9000
|
# • 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_BUCKET=beenvoice-receipts
|
||||||
S3_ACCESS_KEY=minioadmin
|
S3_ACCESS_KEY=GK3515373e4c851ebaad366558
|
||||||
S3_SECRET_KEY=minioadmin
|
S3_SECRET_KEY=7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34
|
||||||
S3_REGION=us-east-1
|
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
|
# docker-compose.yml sets S3_ENDPOINT=http://garage:3900 inside the app container
|
||||||
# automatically. MINIO_ROOT_* below must match S3_ACCESS_KEY / S3_SECRET_KEY.
|
# automatically. S3_ACCESS_KEY / S3_SECRET_KEY must match the garage service env.
|
||||||
MINIO_ROOT_USER=minioadmin
|
|
||||||
MINIO_ROOT_PASSWORD=minioadmin
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# SSO — Authentik OIDC (optional)
|
# SSO — Authentik OIDC (optional)
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|||||||
ARG BETTER_AUTH_URL=http://localhost:3000
|
ARG BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
# Low-memory Docker build profile:
|
# 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)
|
# - skip tsc inside `next build` (run `bun run check` in CI instead)
|
||||||
ENV DOCKER_BUILD=1 \
|
ENV DOCKER_BUILD=1 \
|
||||||
DISABLE_REACT_COMPILER=1 \
|
|
||||||
NODE_ENV=production \
|
NODE_ENV=production \
|
||||||
SKIP_ENV_VALIDATION=1 \
|
SKIP_ENV_VALIDATION=1 \
|
||||||
NEXT_TELEMETRY_DISABLED=1 \
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
|||||||
@@ -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.
|
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
|
### 3. Updating an existing deploy
|
||||||
|
|
||||||
```bash
|
```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`.
|
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
|
### 4. Sign-ups
|
||||||
|
|
||||||
Registration is **enabled** by default. To block new email/password accounts:
|
Registration is **enabled** by default. To block new email/password accounts:
|
||||||
@@ -190,7 +206,9 @@ beenvoice-web/
|
|||||||
├── src/lib/ # auth, PDF, email, branding helpers
|
├── src/lib/ # auth, PDF, email, branding helpers
|
||||||
├── drizzle/ # SQL migrations
|
├── drizzle/ # SQL migrations
|
||||||
├── Dockerfile # Production image (migrate + next start)
|
├── 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)
|
├── docker-compose.dev.yml # Postgres only (local dev)
|
||||||
└── docs/ # Architecture and UI guides
|
└── docs/ # Architecture and UI guides
|
||||||
```
|
```
|
||||||
@@ -250,6 +268,7 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
|
|||||||
| Doc | Contents |
|
| Doc | Contents |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP |
|
| [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 |
|
| [docs/README.md](./docs/README.md) | Index of UI and product guides |
|
||||||
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development |
|
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development |
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
@@ -17,41 +17,58 @@ services:
|
|||||||
- "${POSTGRES_PORT:-5432}:5432"
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# S3-compatible receipt storage for host dev (`bun dev`). API :9000, console :9001.
|
# S3-compatible receipt storage for host dev (`bun dev`). API :3900.
|
||||||
minio:
|
garage:
|
||||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
image: dxflrs/garage:v2.3.0
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
|
||||||
|
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||||
|
configs:
|
||||||
|
- source: garage_config
|
||||||
|
target: /etc/garage.toml
|
||||||
volumes:
|
volumes:
|
||||||
- beenvoice_dev_minio_data:/data
|
- beenvoice_dev_garage_meta:/var/lib/garage/meta
|
||||||
command: server /data --console-address ":9001"
|
- beenvoice_dev_garage_data:/var/lib/garage/data
|
||||||
|
command: ["/garage", "server", "--single-node", "--default-bucket"]
|
||||||
ports:
|
ports:
|
||||||
- "${MINIO_API_PORT:-9000}:9000"
|
- "${GARAGE_API_PORT:-3900}:3900"
|
||||||
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mc", "ready", "local"]
|
test: ["CMD", "/garage", "status"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 15
|
||||||
|
start_period: 20s
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
beenvoice_dev_pg_data:
|
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
@@ -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
|
# After git pull, rebuild before starting — a plain `docker compose up -d` reuses
|
||||||
# the existing local image and will NOT include new code. Use:
|
# 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_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
|
||||||
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
|
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
|
||||||
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
|
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
|
||||||
|
CRON_SECRET: ${CRON_SECRET:-}
|
||||||
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
|
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
|
||||||
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
||||||
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
||||||
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
|
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
|
||||||
S3_ENDPOINT: http://minio:9000
|
S3_ENDPOINT: http://garage:3900
|
||||||
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||||
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
|
||||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
S3_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
|
||||||
S3_REGION: ${S3_REGION:-us-east-1}
|
S3_REGION: ${S3_REGION:-garage}
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-${PORT:-3000}}:3000"
|
- "${WEB_PORT:-${PORT:-3000}}:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
garage:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio-init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
db:
|
db:
|
||||||
@@ -63,41 +66,58 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# S3-compatible receipt storage. API :9000, web console :9001 (host-mapped in dev compose).
|
# S3-compatible receipt storage (~50–100 MB RAM vs MinIO). API :3900.
|
||||||
minio:
|
garage:
|
||||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
image: dxflrs/garage:v2.3.0
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
|
||||||
|
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||||
|
configs:
|
||||||
|
- source: garage_config
|
||||||
|
target: /etc/garage.toml
|
||||||
volumes:
|
volumes:
|
||||||
- beenvoice_minio_data:/data
|
- beenvoice_garage_meta:/var/lib/garage/meta
|
||||||
command: server /data --console-address ":9001"
|
- beenvoice_garage_data:/var/lib/garage/data
|
||||||
|
command: ["/garage", "server", "--single-node", "--default-bucket"]
|
||||||
ports:
|
ports:
|
||||||
- "${MINIO_API_PORT:-9000}:9000"
|
- "${GARAGE_API_PORT:-3900}:3900"
|
||||||
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mc", "ready", "local"]
|
test: ["CMD", "/garage", "status"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 15
|
||||||
|
start_period: 20s
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
beenvoice_pg_data:
|
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
@@ -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 (~50–100 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.
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract |
|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract |
|
||||||
| [../README.md](../README.md) | Install, scripts, deployment |
|
| [../README.md](../README.md) | Install, scripts, deployment |
|
||||||
|
| [COOLIFY.md](./COOLIFY.md) | Coolify + Garage networking (`ENOTFOUND garage`) |
|
||||||
|
|
||||||
## UI & product guides
|
## UI & product guides
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -157,10 +157,10 @@
|
|||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 22,
|
"idx": 23,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1782100000000,
|
"when": 1782200000000,
|
||||||
"tag": "0022_expense_business_receipts",
|
"tag": "0023_invoice_item_time_entry",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ set -euo pipefail
|
|||||||
|
|
||||||
# Production deploy helper for docker-compose.yml (not docker-compose.dev.yml).
|
# 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
|
# 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
|
# (app, db, garage). Receipt storage uses in-stack Garage unless S3_* are
|
||||||
# overridden in .env. MinIO API/console: localhost:${MINIO_API_PORT:-9000} / :9001.
|
# 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
|
# 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.
|
# changes from `git pull`. Always pass --build or use this script after pulling.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { users } from "~/server/db/schema";
|
import { users } from "~/server/db/schema";
|
||||||
import { sendPasswordResetForUser } from "~/lib/password-reset";
|
import { sendPasswordResetForUser } from "~/lib/password-reset";
|
||||||
|
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -12,8 +13,24 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
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@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(email)) {
|
if (!emailRegex.test(normalizedEmail)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid email format" },
|
{ error: "Invalid email format" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -21,7 +38,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: eq(users.email, email.toLowerCase()),
|
where: eq(users.email, normalizedEmail),
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|||||||
import { auth } from "~/lib/auth";
|
import { auth } from "~/lib/auth";
|
||||||
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
|
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
|
||||||
import { resolveNewUserRole } from "~/lib/first-admin";
|
import { resolveNewUserRole } from "~/lib/first-admin";
|
||||||
|
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { accounts, users } from "~/server/db/schema";
|
import { accounts, users } from "~/server/db/schema";
|
||||||
@@ -71,6 +72,12 @@ function formatRegisterError(error: z.ZodError): string {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const rateLimit = requireRateLimit(rateLimitKey(request, "auth:register"), {
|
||||||
|
windowMs: 60 * 60 * 1000,
|
||||||
|
max: 5,
|
||||||
|
});
|
||||||
|
if (rateLimit) return rateLimit;
|
||||||
|
|
||||||
if (env.DISABLE_SIGNUPS === true) {
|
if (env.DISABLE_SIGNUPS === true) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "New account registration is currently disabled" },
|
{ 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 { firstName, lastName, email, password } = parsed.data;
|
||||||
const normalizedEmail = email.toLowerCase();
|
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({
|
const existingUser = await db.query.users.findFirst({
|
||||||
where: eq(users.email, normalizedEmail),
|
where: eq(users.email, normalizedEmail),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "User with this email already exists" },
|
{ error: "Registration failed. Please check the form or sign in." },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { eq, and, gt } from "drizzle-orm";
|
import { eq, and, gt } from "drizzle-orm";
|
||||||
import bcrypt from "bcryptjs";
|
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 { db } from "~/server/db";
|
||||||
import { accounts, users } from "~/server/db/schema";
|
import { accounts, users } from "~/server/db/schema";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:reset"), {
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
});
|
||||||
|
if (ipRateLimit) return ipRateLimit;
|
||||||
|
|
||||||
const { token, password } = (await request.json()) as {
|
const { token, password } = (await request.json()) as {
|
||||||
token: string;
|
token: string;
|
||||||
password: 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
|
// Find user with valid reset token that hasn't expired
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(users.resetToken, token),
|
eq(users.resetToken, tokenHash),
|
||||||
gt(users.resetTokenExpiry, new Date()),
|
gt(users.resetTokenExpiry, new Date()),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -82,6 +102,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await revokeUserSessions(user.id);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,20 +1,39 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { eq, and, gt } from "drizzle-orm";
|
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 { db } from "~/server/db";
|
||||||
import { users } from "~/server/db/schema";
|
import { users } from "~/server/db/schema";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
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 };
|
const { token } = (await request.json()) as { token: string };
|
||||||
|
|
||||||
if (!token || typeof token !== "string") {
|
if (!token || typeof token !== "string") {
|
||||||
return NextResponse.json({ error: "Token is required" }, { status: 400 });
|
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
|
// Find user with valid reset token that hasn't expired
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(users.resetToken, token),
|
eq(users.resetToken, tokenHash),
|
||||||
gt(users.resetTokenExpiry, new Date()),
|
gt(users.resetTokenExpiry, new Date()),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ export async function POST(req: NextRequest) {
|
|||||||
const authHeader = req.headers.get("authorization");
|
const authHeader = req.headers.get("authorization");
|
||||||
const secret = env.CRON_SECRET;
|
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 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { getOptionalServerSession } from "~/lib/auth-server";
|
import { getOptionalServerSession } from "~/lib/auth-server";
|
||||||
import { getObject } from "~/lib/object-storage";
|
import { getObject } from "~/lib/object-storage";
|
||||||
@@ -20,7 +20,7 @@ export async function GET(
|
|||||||
with: { expense: true },
|
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 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Input } from "~/components/ui/input";
|
|||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { authClient } from "~/lib/auth-client";
|
import { authClient } from "~/lib/auth-client";
|
||||||
|
import { safeCallbackPath } from "~/lib/safe-callback-url";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface SignInFormProps {
|
interface SignInFormProps {
|
||||||
@@ -25,7 +26,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
|||||||
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
const callbackUrl = safeCallbackPath(searchParams.get("callbackUrl"));
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -39,10 +40,17 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
const message = error.message?.toLowerCase() ?? "";
|
||||||
|
const rateLimited =
|
||||||
|
error.status === 429 ||
|
||||||
|
message.includes("too many") ||
|
||||||
|
message.includes("rate limit");
|
||||||
toast.error(
|
toast.error(
|
||||||
error.message && error.message !== "Required"
|
rateLimited
|
||||||
? error.message
|
? "Too many sign-in attempts. Please wait a moment and try again."
|
||||||
: "Invalid email or password",
|
: error.message && error.message !== "Required"
|
||||||
|
? error.message
|
||||||
|
: "Invalid email or password",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+602
-421
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>
|
||||||
|
“{receipt.originalFilename}” 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (!serverColorMode?.colorMode) return;
|
||||||
if (serverHydratedRef.current) return;
|
if (serverHydratedRef.current) return;
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setColorMode(serverColorMode.colorMode);
|
setColorMode(serverColorMode.colorMode);
|
||||||
serverHydratedRef.current = true;
|
serverHydratedRef.current = true;
|
||||||
}, [serverColorMode?.colorMode]);
|
}, [serverColorMode?.colorMode]);
|
||||||
|
|||||||
@@ -37,11 +37,68 @@ import {
|
|||||||
} from "~/lib/time-clock";
|
} from "~/lib/time-clock";
|
||||||
import { invoiceLabel } from "~/lib/time-entry-display";
|
import { invoiceLabel } from "~/lib/time-entry-display";
|
||||||
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
|
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
|
||||||
|
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
|
||||||
|
|
||||||
const FEATURED_CLIENT_COUNT = 4;
|
const FEATURED_CLIENT_COUNT = 4;
|
||||||
|
|
||||||
type StartMode = "now" | "pick" | "ago";
|
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 = {
|
export type TimeClockPanelProps = {
|
||||||
defaultClientId?: string;
|
defaultClientId?: string;
|
||||||
defaultInvoiceId?: string;
|
defaultInvoiceId?: string;
|
||||||
@@ -109,6 +166,7 @@ export function TimeClockPanel({
|
|||||||
const [startMode, setStartMode] = useState<StartMode>("now");
|
const [startMode, setStartMode] = useState<StartMode>("now");
|
||||||
const [pickedStart, setPickedStart] = useState("");
|
const [pickedStart, setPickedStart] = useState("");
|
||||||
const [minutesAgo, setMinutesAgo] = useState("30");
|
const [minutesAgo, setMinutesAgo] = useState("30");
|
||||||
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const draftClientId = running ? (running.clientId ?? "") : clientId;
|
const draftClientId = running ? (running.clientId ?? "") : clientId;
|
||||||
@@ -185,6 +243,18 @@ export function TimeClockPanel({
|
|||||||
onError: (e) => toast.error(e.message),
|
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({
|
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const message = describeClockOutOutcome({
|
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">
|
<div className="space-y-2">
|
||||||
<Label>Client</Label>
|
<Label>Client</Label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -621,7 +699,10 @@ export function TimeClockPanel({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{todayEntries?.some((e) => e.endedAt) ? (
|
{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">
|
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||||
No entries today.{" "}
|
No entries today.{" "}
|
||||||
@@ -637,6 +718,13 @@ export function TimeClockPanel({
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<TimeEntryEditDialog
|
||||||
|
entryId={editEntryId}
|
||||||
|
open={editEntryId != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditEntryId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -9,9 +9,12 @@ import { EmptyState } from "~/components/layout/page-layout";
|
|||||||
import { Clock, Play } from "lucide-react";
|
import { Clock, Play } from "lucide-react";
|
||||||
import { groupEntriesByDate } from "~/lib/time-entry-display";
|
import { groupEntriesByDate } from "~/lib/time-entry-display";
|
||||||
import { TimeEntryRow } from "~/components/time-clock/time-entry-list";
|
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() {
|
export function TimeEntriesHistory() {
|
||||||
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
|
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
|
||||||
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||||
|
|
||||||
const completedEntries = useMemo(
|
const completedEntries = useMemo(
|
||||||
() => (entries ?? []).filter((e) => e.endedAt),
|
() => (entries ?? []).filter((e) => e.endedAt),
|
||||||
@@ -57,25 +60,35 @@ export function TimeEntriesHistory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
{grouped.map((group) => (
|
<div className="space-y-6">
|
||||||
<Card key={group.dateKey}>
|
{grouped.map((group) => (
|
||||||
<CardHeader className="pb-2">
|
<Card key={group.dateKey}>
|
||||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
<CardHeader className="pb-2">
|
||||||
{group.label}
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
</CardTitle>
|
{group.label}
|
||||||
</CardHeader>
|
</CardTitle>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
{group.entries.map((entry, index) => (
|
<CardContent>
|
||||||
<TimeEntryRow
|
{group.entries.map((entry, index) => (
|
||||||
key={entry.id}
|
<TimeEntryRow
|
||||||
entry={entry}
|
key={entry.id}
|
||||||
isLast={index === group.entries.length - 1}
|
entry={entry}
|
||||||
/>
|
isLast={index === group.entries.length - 1}
|
||||||
))}
|
onEdit={(item: TimeEntryListItem) => setEditEntryId(item.id)}
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
))}
|
||||||
))}
|
</CardContent>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,11 +6,13 @@ import { entryHref, invoiceLabel, type TimeEntryListItem } from "~/lib/time-entr
|
|||||||
export function TimeEntryRow({
|
export function TimeEntryRow({
|
||||||
entry,
|
entry,
|
||||||
isLast,
|
isLast,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
entry: TimeEntryListItem;
|
entry: TimeEntryListItem;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
|
onEdit?: (entry: TimeEntryListItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const href = entryHref(entry);
|
const href = onEdit ? null : entryHref(entry);
|
||||||
const rowClassName = cn(
|
const rowClassName = cn(
|
||||||
"flex items-start justify-between gap-4 py-3",
|
"flex items-start justify-between gap-4 py-3",
|
||||||
!isLast && "border-border border-b",
|
!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 (
|
return (
|
||||||
<div className={rowClassName}>
|
<div className={rowClassName}>
|
||||||
{content}
|
{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);
|
const completed = entries.filter((e) => e.endedAt);
|
||||||
|
|
||||||
if (completed.length === 0) return null;
|
if (completed.length === 0) return null;
|
||||||
@@ -69,6 +92,7 @@ export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
|
|||||||
key={entry.id}
|
key={entry.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
isLast={index === completed.length - 1}
|
isLast={index === completed.length - 1}
|
||||||
|
onEdit={onEdit}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+6
-1
@@ -34,13 +34,17 @@ export const env = createEnv({
|
|||||||
.default("development"),
|
.default("development"),
|
||||||
DB_DISABLE_SSL: optionalEnvBoolean(),
|
DB_DISABLE_SSL: optionalEnvBoolean(),
|
||||||
DISABLE_SIGNUPS: optionalEnvBoolean().default(true),
|
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-compatible object storage (optional — local .data/receipts/ fallback when unset)
|
||||||
S3_ENDPOINT: z.string().url().optional(),
|
S3_ENDPOINT: z.string().url().optional(),
|
||||||
S3_BUCKET: z.string().optional(),
|
S3_BUCKET: z.string().optional(),
|
||||||
S3_ACCESS_KEY: z.string().optional(),
|
S3_ACCESS_KEY: z.string().optional(),
|
||||||
S3_SECRET_KEY: z.string().optional(),
|
S3_SECRET_KEY: z.string().optional(),
|
||||||
S3_REGION: z.string().optional(),
|
S3_REGION: z.string().optional(),
|
||||||
|
S3_FORCE_PATH_STYLE: optionalEnvBoolean(),
|
||||||
// SSO / Authentik (optional)
|
// SSO / Authentik (optional)
|
||||||
AUTHENTIK_ISSUER: z.string().url().optional(),
|
AUTHENTIK_ISSUER: z.string().url().optional(),
|
||||||
AUTHENTIK_CLIENT_ID: z.string().optional(),
|
AUTHENTIK_CLIENT_ID: z.string().optional(),
|
||||||
@@ -87,6 +91,7 @@ export const env = createEnv({
|
|||||||
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
|
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
|
||||||
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
|
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
|
||||||
S3_REGION: process.env.S3_REGION,
|
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_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||||
|
|||||||
+65
-4
@@ -1,15 +1,76 @@
|
|||||||
import { headers as nextHeaders } from "next/headers";
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import { auth } from "~/lib/auth";
|
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 {
|
export function hasSessionCookie(headers: Headers): boolean {
|
||||||
const cookie = headers.get("cookie") ?? "";
|
const cookie = headers.get("cookie") ?? "";
|
||||||
return (
|
if (!cookie.trim()) return false;
|
||||||
cookie.includes("better-auth.session_token=") ||
|
|
||||||
cookie.includes("__Secure-better-auth.session_token=")
|
return looksLikeSessionCookie(cookie);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOptionalServerSession(headers: Headers) {
|
export async function getOptionalServerSession(headers: Headers) {
|
||||||
|
headers = headersWithAuthCookieFallback(headers);
|
||||||
|
|
||||||
if (!hasSessionCookie(headers)) {
|
if (!hasSessionCookie(headers)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-20
@@ -5,6 +5,7 @@ import { nextCookies } from "better-auth/next-js";
|
|||||||
import { genericOAuth } from "better-auth/plugins";
|
import { genericOAuth } from "better-auth/plugins";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
|
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
|
||||||
|
import { sendPasswordResetEmail } from "~/lib/password-reset";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import * as schema from "~/server/db/schema";
|
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.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []),
|
||||||
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
|
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
|
||||||
"beenvoice://",
|
"beenvoice://",
|
||||||
"exp://",
|
...(env.NODE_ENV === "development"
|
||||||
|
? ["exp://", "http://localhost:3000", "http://127.0.0.1:3000"]
|
||||||
|
: []),
|
||||||
...(authentikOrigin ? [authentikOrigin] : []),
|
...(authentikOrigin ? [authentikOrigin] : []),
|
||||||
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
|
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
|
||||||
];
|
];
|
||||||
@@ -37,6 +40,29 @@ export const auth = betterAuth({
|
|||||||
advanced: {
|
advanced: {
|
||||||
trustedProxyHeaders: true,
|
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: {
|
experimental: {
|
||||||
joins: true,
|
joins: true,
|
||||||
},
|
},
|
||||||
@@ -61,25 +87,7 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
trustedOrigins: async (request) => {
|
trustedOrigins: staticTrustedOrigins,
|
||||||
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;
|
|
||||||
},
|
|
||||||
...(authentikEnabled && {
|
...(authentikEnabled && {
|
||||||
accountLinking: {
|
accountLinking: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -89,6 +97,16 @@ export const auth = betterAuth({
|
|||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
disableSignUp: signupsDisabled,
|
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: {
|
password: {
|
||||||
hash: async (password) => {
|
hash: async (password) => {
|
||||||
const bcrypt = await import("bcryptjs");
|
const bcrypt = await import("bcryptjs");
|
||||||
|
|||||||
+77
-22
@@ -21,13 +21,62 @@ type S3Module = typeof import("@aws-sdk/client-s3");
|
|||||||
|
|
||||||
let s3ModulePromise: Promise<S3Module> | null = null;
|
let s3ModulePromise: Promise<S3Module> | null = null;
|
||||||
let s3Client: InstanceType<S3Module["S3Client"]> | 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() {
|
async function getS3() {
|
||||||
if (!s3ModulePromise) {
|
s3ModulePromise ??= import("@aws-sdk/client-s3");
|
||||||
s3ModulePromise = import("@aws-sdk/client-s3");
|
|
||||||
}
|
|
||||||
const mod = await s3ModulePromise;
|
const mod = await s3ModulePromise;
|
||||||
if (!s3Client) {
|
if (!s3Client) {
|
||||||
|
logBareGarageEndpointHint();
|
||||||
s3Client = new mod.S3Client({
|
s3Client = new mod.S3Client({
|
||||||
region: process.env.S3_REGION ?? "us-east-1",
|
region: process.env.S3_REGION ?? "us-east-1",
|
||||||
endpoint: process.env.S3_ENDPOINT,
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
@@ -35,8 +84,8 @@ async function getS3() {
|
|||||||
accessKeyId: process.env.S3_ACCESS_KEY!,
|
accessKeyId: process.env.S3_ACCESS_KEY!,
|
||||||
secretAccessKey: process.env.S3_SECRET_KEY!,
|
secretAccessKey: process.env.S3_SECRET_KEY!,
|
||||||
},
|
},
|
||||||
// Required for MinIO and most S3-compatible endpoints.
|
// Required for Garage and most S3-compatible endpoints (including HTTPS proxies).
|
||||||
forcePathStyle: Boolean(process.env.S3_ENDPOINT),
|
forcePathStyle: shouldForcePathStyle(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { client: s3Client, ...mod };
|
return { client: s3Client, ...mod };
|
||||||
@@ -53,13 +102,15 @@ export async function putObject(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (isS3Configured()) {
|
if (isS3Configured()) {
|
||||||
const { client, PutObjectCommand } = await getS3();
|
const { client, PutObjectCommand } = await getS3();
|
||||||
await client.send(
|
await withS3Diagnostics(() =>
|
||||||
new PutObjectCommand({
|
client.send(
|
||||||
Bucket: process.env.S3_BUCKET!,
|
new PutObjectCommand({
|
||||||
Key: key,
|
Bucket: process.env.S3_BUCKET!,
|
||||||
Body: body,
|
Key: key,
|
||||||
ContentType: contentType,
|
Body: body,
|
||||||
}),
|
ContentType: contentType,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -72,11 +123,13 @@ export async function putObject(
|
|||||||
export async function getObject(key: string): Promise<Buffer> {
|
export async function getObject(key: string): Promise<Buffer> {
|
||||||
if (isS3Configured()) {
|
if (isS3Configured()) {
|
||||||
const { client, GetObjectCommand } = await getS3();
|
const { client, GetObjectCommand } = await getS3();
|
||||||
const response = await client.send(
|
const response = await withS3Diagnostics(() =>
|
||||||
new GetObjectCommand({
|
client.send(
|
||||||
Bucket: process.env.S3_BUCKET!,
|
new GetObjectCommand({
|
||||||
Key: key,
|
Bucket: process.env.S3_BUCKET!,
|
||||||
}),
|
Key: key,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const bytes = await response.Body?.transformToByteArray();
|
const bytes = await response.Body?.transformToByteArray();
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
@@ -91,11 +144,13 @@ export async function getObject(key: string): Promise<Buffer> {
|
|||||||
export async function deleteObject(key: string): Promise<void> {
|
export async function deleteObject(key: string): Promise<void> {
|
||||||
if (isS3Configured()) {
|
if (isS3Configured()) {
|
||||||
const { client, DeleteObjectCommand } = await getS3();
|
const { client, DeleteObjectCommand } = await getS3();
|
||||||
await client.send(
|
await withS3Diagnostics(() =>
|
||||||
new DeleteObjectCommand({
|
client.send(
|
||||||
Bucket: process.env.S3_BUCKET!,
|
new DeleteObjectCommand({
|
||||||
Key: key,
|
Bucket: process.env.S3_BUCKET!,
|
||||||
}),
|
Key: key,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-36
@@ -1,10 +1,13 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
|
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
|
||||||
import { getAppUrl } from "~/lib/app-url";
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
||||||
|
import {
|
||||||
|
createPasswordResetToken,
|
||||||
|
hashPasswordResetToken,
|
||||||
|
} from "~/lib/reset-token";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { users } from "~/server/db/schema";
|
import { users } from "~/server/db/schema";
|
||||||
|
|
||||||
@@ -14,6 +17,45 @@ export type PasswordResetResult = {
|
|||||||
userEmail?: string;
|
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(
|
export async function sendPasswordResetForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<PasswordResetResult> {
|
): Promise<PasswordResetResult> {
|
||||||
@@ -26,44 +68,18 @@ export async function sendPasswordResetForUser(
|
|||||||
return { success: false, emailSent: false };
|
return { success: false, emailSent: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetToken = crypto.randomBytes(32).toString("hex");
|
const resetToken = createPasswordResetToken();
|
||||||
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
const resetTokenHash = hashPasswordResetToken(resetToken);
|
||||||
|
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ resetToken, resetTokenExpiry })
|
.set({ resetToken: resetTokenHash, resetTokenExpiry })
|
||||||
.where(eq(users.id, user.id));
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
if (!env.RESEND_API_KEY) {
|
return sendPasswordResetEmail({
|
||||||
console.warn(
|
userEmail: user.email,
|
||||||
"Password reset requested, but RESEND_API_KEY is not configured.",
|
userName: user.name ?? undefined,
|
||||||
);
|
resetToken,
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,6 +1,17 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { isPublicRoute } from "~/lib/public-routes";
|
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) {
|
export function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
@@ -18,15 +29,13 @@ export function proxy(request: NextRequest) {
|
|||||||
return NextResponse.next();
|
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 no session token, redirect to sign-in
|
||||||
if (!sessionToken) {
|
if (!hasBetterAuthSessionCookie(request)) {
|
||||||
const signInUrl = new URL("/auth/signin", request.url);
|
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);
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,22 +7,11 @@ import {
|
|||||||
getApiKeyDisplayPrefix,
|
getApiKeyDisplayPrefix,
|
||||||
hashApiKey,
|
hashApiKey,
|
||||||
} from "~/server/api/api-keys";
|
} 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";
|
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({
|
export const apiKeysRouter = createTRPCRouter({
|
||||||
list: protectedProcedure.query(async ({ ctx }) => {
|
list: sessionProcedure.query(async ({ ctx }) => {
|
||||||
requireSessionAuth(ctx);
|
|
||||||
|
|
||||||
return ctx.db.query.apiKeys.findMany({
|
return ctx.db.query.apiKeys.findMany({
|
||||||
where: eq(apiKeys.userId, ctx.session.user.id),
|
where: eq(apiKeys.userId, ctx.session.user.id),
|
||||||
columns: {
|
columns: {
|
||||||
@@ -39,7 +28,7 @@ export const apiKeysRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
create: protectedProcedure
|
create: sessionProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().min(1).max(100),
|
||||||
@@ -47,8 +36,6 @@ export const apiKeysRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requireSessionAuth(ctx);
|
|
||||||
|
|
||||||
if (input.expiresAt && input.expiresAt <= new Date()) {
|
if (input.expiresAt && input.expiresAt <= new Date()) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
@@ -84,11 +71,9 @@ export const apiKeysRouter = createTRPCRouter({
|
|||||||
return { ...apiKey, key };
|
return { ...apiKey, key };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
revoke: protectedProcedure
|
revoke: sessionProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requireSessionAuth(ctx);
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [apiKey] = await ctx.db
|
const [apiKey] = await ctx.db
|
||||||
.update(apiKeys)
|
.update(apiKeys)
|
||||||
@@ -108,9 +93,7 @@ export const apiKeysRouter = createTRPCRouter({
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
revokeAll: protectedProcedure.mutation(async ({ ctx }) => {
|
revokeAll: sessionProcedure.mutation(async ({ ctx }) => {
|
||||||
requireSessionAuth(ctx);
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(apiKeys)
|
.update(apiKeys)
|
||||||
|
|||||||
@@ -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 { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import { clients, invoices } from "~/server/db/schema";
|
import { clients, invoices } from "~/server/db/schema";
|
||||||
@@ -172,6 +172,7 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
userInvoices,
|
userInvoices,
|
||||||
userClientsCount,
|
userClientsCount,
|
||||||
recentInvoices,
|
recentInvoices,
|
||||||
|
monthInvoices,
|
||||||
currentDraft,
|
currentDraft,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
ctx.db.query.invoices.findMany({
|
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({
|
ctx.db.query.invoices.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(invoices.createdById, userId),
|
eq(invoices.createdById, userId),
|
||||||
@@ -227,6 +255,7 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
...metrics,
|
...metrics,
|
||||||
totalClients: userClientsCount,
|
totalClients: userClientsCount,
|
||||||
recentInvoices,
|
recentInvoices,
|
||||||
|
monthInvoices,
|
||||||
currentDraft: currentDraft
|
currentDraft: currentDraft
|
||||||
? {
|
? {
|
||||||
id: currentDraft.id,
|
id: currentDraft.id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Resend } from "resend";
|
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 { invoices, platformSettings } from "~/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
@@ -36,7 +36,7 @@ function normalizeEmailNoteHtml(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const emailRouter = createTRPCRouter({
|
export const emailRouter = createTRPCRouter({
|
||||||
sendInvoice: protectedProcedure
|
sendInvoice: sessionProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
invoiceId: z.string(),
|
invoiceId: z.string(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
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 { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import {
|
import {
|
||||||
expenses,
|
expenses,
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
putObject,
|
putObject,
|
||||||
RECEIPT_MAX_BYTES,
|
RECEIPT_MAX_BYTES,
|
||||||
} from "~/lib/object-storage";
|
} from "~/lib/object-storage";
|
||||||
|
import { parseReceiptText } from "~/lib/receipt-parse";
|
||||||
|
|
||||||
export { EXPENSE_CATEGORIES };
|
export { EXPENSE_CATEGORIES };
|
||||||
|
|
||||||
@@ -132,16 +133,67 @@ export const expensesRouter = createTRPCRouter({
|
|||||||
conditions.push(eq(expenses.businessId, input.businessId));
|
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),
|
where: and(...conditions),
|
||||||
with: {
|
with: {
|
||||||
client: true,
|
client: true,
|
||||||
business: true,
|
business: true,
|
||||||
invoice: true,
|
invoice: true,
|
||||||
receipts: true,
|
|
||||||
},
|
},
|
||||||
orderBy: [desc(expenses.date)],
|
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
|
getById: protectedProcedure
|
||||||
@@ -380,8 +432,7 @@ export const expensesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!receipt ||
|
receipt?.expense.createdById !== ctx.session.user.id
|
||||||
receipt.expense.createdById !== ctx.session.user.id
|
|
||||||
) {
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
@@ -396,4 +447,16 @@ export const expensesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true };
|
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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
publicProcedure,
|
||||||
|
sessionProcedure,
|
||||||
|
} from "../trpc";
|
||||||
import {
|
import {
|
||||||
invoices,
|
invoices,
|
||||||
invoiceItems,
|
invoiceItems,
|
||||||
@@ -754,7 +759,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
return { success: true, deleted: ownedIds.length };
|
return { success: true, deleted: ownedIds.length };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
bulkImport: protectedProcedure
|
bulkImport: sessionProcedure
|
||||||
.input(bulkImportSchema)
|
.input(bulkImportSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
@@ -998,7 +1003,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// ── Public token (shareable link) ──────────────────────────────────────────
|
// ── Public token (shareable link) ──────────────────────────────────────────
|
||||||
|
|
||||||
generatePublicToken: protectedProcedure
|
generatePublicToken: sessionProcedure
|
||||||
.input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() }))
|
.input(z.object({ id: z.string(), ttlHours: z.number().positive().optional() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
@@ -1018,7 +1023,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
return { token, expiresAt };
|
return { token, expiresAt };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
revokePublicToken: protectedProcedure
|
revokePublicToken: sessionProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
@@ -1060,7 +1065,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// ── Send reminder ──────────────────────────────────────────────────────────
|
// ── Send reminder ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
sendReminder: protectedProcedure
|
sendReminder: sessionProcedure
|
||||||
.input(z.object({ id: z.string(), customMessage: z.string().optional() }))
|
.input(z.object({ id: z.string(), customMessage: z.string().optional() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
|
sessionProcedure,
|
||||||
} from "~/server/api/trpc";
|
} from "~/server/api/trpc";
|
||||||
import { requireAdmin } from "~/server/api/require-admin";
|
import { requireAdmin } from "~/server/api/require-admin";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
pdfTemplateSchema,
|
pdfTemplateSchema,
|
||||||
type ColorMode,
|
type ColorMode,
|
||||||
} from "~/lib/branding";
|
} from "~/lib/branding";
|
||||||
|
import { revokeUserSessions } from "~/lib/session-security";
|
||||||
|
|
||||||
function resolveBusinessId(
|
function resolveBusinessId(
|
||||||
refs: { businessName?: string; businessNickname?: string },
|
refs: { businessName?: string; businessNickname?: string },
|
||||||
@@ -512,7 +514,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// Change user password
|
// Change user password
|
||||||
changePassword: protectedProcedure
|
changePassword: sessionProcedure
|
||||||
.input(
|
.input(
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
@@ -595,11 +597,13 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await revokeUserSessions(userId, ctx.session.session?.token);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Export user data (backup)
|
// Export user data (backup)
|
||||||
exportData: protectedProcedure.query(async ({ ctx }) => {
|
exportData: sessionProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
const user = await ctx.db.query.users.findFirst({
|
const user = await ctx.db.query.users.findFirst({
|
||||||
@@ -855,7 +859,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// Import user data (restore)
|
// Import user data (restore)
|
||||||
importData: protectedProcedure
|
importData: sessionProcedure
|
||||||
.input(BackupDataSchema)
|
.input(BackupDataSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
@@ -1168,7 +1172,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// Delete all user data (for account deletion)
|
// Delete all user data (for account deletion)
|
||||||
deleteAllData: protectedProcedure
|
deleteAllData: sessionProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
confirmText: z.string().refine((val) => val === "DELETE ALL DATA", {
|
confirmText: z.string().refine((val) => val === "DELETE ALL DATA", {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
|
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
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 { TRPCError } from "@trpc/server";
|
||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
type ClockOutOutcome,
|
type ClockOutOutcome,
|
||||||
} from "~/lib/time-clock";
|
} from "~/lib/time-clock";
|
||||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||||
|
import {
|
||||||
|
insertInvoiceLineForTimeEntry,
|
||||||
|
relinkTimeEntryToInvoice,
|
||||||
|
removeLinkedInvoiceItem,
|
||||||
|
syncLinkedInvoiceItem,
|
||||||
|
} from "~/server/api/lib/time-entry-invoice-sync";
|
||||||
|
|
||||||
type Db = typeof db;
|
type Db = typeof db;
|
||||||
|
|
||||||
@@ -55,37 +61,14 @@ async function addEntryToInvoice(
|
|||||||
rate: number,
|
rate: number,
|
||||||
date: Date,
|
date: Date,
|
||||||
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
|
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
|
||||||
const amount = hours * rate;
|
return insertInvoiceLineForTimeEntry(database, {
|
||||||
const maxPosition = invoice.items.reduce((m, item) => Math.max(m, item.position), -1);
|
invoice,
|
||||||
|
entryId,
|
||||||
await database.insert(invoiceItems).values({
|
|
||||||
invoiceId: invoice.id,
|
|
||||||
date,
|
|
||||||
description,
|
description,
|
||||||
hours,
|
hours,
|
||||||
rate,
|
rate,
|
||||||
amount,
|
date,
|
||||||
position: maxPosition + 1,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(
|
async function findOrCreateDraftInvoice(
|
||||||
@@ -226,7 +209,10 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
),
|
),
|
||||||
with: {
|
with: {
|
||||||
client: true,
|
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;
|
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;
|
let clientRecord: { defaultHourlyRate: number | null } | null = null;
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
const found = await ctx.db.query.clients.findFirst({
|
const found = await ctx.db.query.clients.findFirst({
|
||||||
@@ -338,6 +324,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
clientId: z.string().optional().or(z.literal("")),
|
clientId: z.string().optional().or(z.literal("")),
|
||||||
invoiceId: z.string().optional().or(z.literal("")),
|
invoiceId: z.string().optional().or(z.literal("")),
|
||||||
rate: z.number().min(0).optional(),
|
rate: z.number().min(0).optional(),
|
||||||
|
startedAt: z.date().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -357,9 +344,20 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
clientId?: string | null;
|
clientId?: string | null;
|
||||||
invoiceId?: string | null;
|
invoiceId?: string | null;
|
||||||
rate?: number | null;
|
rate?: number | null;
|
||||||
|
startedAt?: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
} = { updatedAt: new 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) {
|
if (input.description !== undefined) {
|
||||||
updates.description = input.description;
|
updates.description = input.description;
|
||||||
}
|
}
|
||||||
@@ -516,7 +514,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(createSchema)
|
.input(createSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const clientId = input.clientId?.trim() ?? null;
|
const clientId = input.clientId?.trim() || null;
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
const client = await ctx.db.query.clients.findFirst({
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
||||||
@@ -563,9 +561,13 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
.input(updateSchema)
|
.input(
|
||||||
|
updateSchema.extend({
|
||||||
|
invoiceId: z.string().optional().or(z.literal("")),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { id, ...data } = input;
|
const { id, invoiceId: nextInvoiceId, ...data } = input;
|
||||||
|
|
||||||
const existing = await ctx.db.query.timeEntries.findFirst({
|
const existing = await ctx.db.query.timeEntries.findFirst({
|
||||||
where: and(
|
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) 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 =
|
const clientId =
|
||||||
data.clientId !== undefined ? data.clientId?.trim() || null : undefined;
|
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" });
|
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
|
await ctx.db
|
||||||
.update(timeEntries)
|
.update(timeEntries)
|
||||||
.set({
|
.set({
|
||||||
...data,
|
...data,
|
||||||
clientId,
|
clientId,
|
||||||
notes: data.notes?.trim() ?? null,
|
hours,
|
||||||
|
notes: data.notes?.trim() ?? undefined,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(timeEntries.id, id));
|
.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 };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -609,6 +641,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
|
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));
|
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|||||||
+45
-5
@@ -12,7 +12,11 @@ import superjson from "superjson";
|
|||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import { auth } from "~/lib/auth";
|
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 { db } from "~/server/db";
|
||||||
import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys";
|
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
|
* @see https://trpc.io/docs/server/context
|
||||||
*/
|
*/
|
||||||
export const createTRPCContext = async (opts: { headers: Headers }) => {
|
export const createTRPCContext = async (opts: { headers: Headers }) => {
|
||||||
const bearerToken = getBearerToken(opts.headers);
|
const headers = headersWithAuthCookieFallback(opts.headers);
|
||||||
|
const bearerToken = getBearerToken(headers);
|
||||||
|
|
||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
const apiKeyAuth = await getUserForApiKey(db, bearerToken);
|
const apiKeyAuth = await getUserForApiKey(db, bearerToken);
|
||||||
@@ -44,23 +49,25 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
|
|||||||
authSource: "api-key" as const,
|
authSource: "api-key" as const,
|
||||||
apiKeyId: apiKeyAuth.apiKeyId,
|
apiKeyId: apiKeyAuth.apiKeyId,
|
||||||
...opts,
|
...opts,
|
||||||
|
headers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasSessionCookie(opts.headers)) {
|
if (!hasSessionCookie(headers)) {
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
session: null,
|
session: null,
|
||||||
authSource: "none" as const,
|
authSource: "none" as const,
|
||||||
apiKeyId: null,
|
apiKeyId: null,
|
||||||
...opts,
|
...opts,
|
||||||
|
headers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: opts.headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -69,6 +76,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
|
|||||||
authSource: session?.user ? ("session" as const) : ("none" as const),
|
authSource: session?.user ? ("session" as const) : ("none" as const),
|
||||||
apiKeyId: null,
|
apiKeyId: null,
|
||||||
...opts,
|
...opts,
|
||||||
|
headers,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[tRPC] Failed to resolve session:", error);
|
console.error("[tRPC] Failed to resolve session:", error);
|
||||||
@@ -79,6 +87,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
|
|||||||
authSource: "none" as const,
|
authSource: "none" as const,
|
||||||
apiKeyId: null,
|
apiKeyId: null,
|
||||||
...opts,
|
...opts,
|
||||||
|
headers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -143,6 +152,24 @@ const timingMiddleware = t.middleware(async ({ next, path }) => {
|
|||||||
return result;
|
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
|
* 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
|
* guarantee that a user querying is authorized, but you can still access user session data if they
|
||||||
* are logged in.
|
* are logged in.
|
||||||
*/
|
*/
|
||||||
export const publicProcedure = t.procedure.use(timingMiddleware);
|
export const publicProcedure = t.procedure
|
||||||
|
.use(timingMiddleware)
|
||||||
|
.use(apiKeyRateLimitMiddleware);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Protected (authenticated) procedure
|
* 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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -432,6 +432,9 @@ export const invoiceItems = createTable(
|
|||||||
rate: d.real().notNull(),
|
rate: d.real().notNull(),
|
||||||
amount: d.real().notNull(),
|
amount: d.real().notNull(),
|
||||||
position: d.integer().notNull().default(0), // NEW: position for ordering
|
position: d.integer().notNull().default(0), // NEW: position for ordering
|
||||||
|
timeEntryId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.references(() => timeEntries.id, { onDelete: "set null" }),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp()
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
@@ -449,6 +452,10 @@ export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({
|
|||||||
fields: [invoiceItems.invoiceId],
|
fields: [invoiceItems.invoiceId],
|
||||||
references: [invoices.id],
|
references: [invoices.id],
|
||||||
}),
|
}),
|
||||||
|
timeEntry: one(timeEntries, {
|
||||||
|
fields: [invoiceItems.timeEntryId],
|
||||||
|
references: [timeEntries.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const expenses = createTable(
|
export const expenses = createTable(
|
||||||
|
|||||||
+44
-34
@@ -8,45 +8,55 @@ import { TRPCClientError } from "@trpc/client";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import SuperJSON from "superjson";
|
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 = () =>
|
export const createQueryClient = () =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
queryCache: new QueryCache({
|
queryCache: new QueryCache({ onError: handleQueryError }),
|
||||||
onError: (error) => {
|
mutationCache: new MutationCache({ onError: handleQueryError }),
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
// With SSR, we usually want to set some default staleTime
|
|
||||||
// above 0 to avoid refetching immediately on the client
|
|
||||||
staleTime: 30 * 1000,
|
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: {
|
dehydrate: {
|
||||||
serializeData: SuperJSON.serialize,
|
serializeData: SuperJSON.serialize,
|
||||||
|
|||||||
Reference in New Issue
Block a user