Convert Beenvoice to a Turborepo monorepo
This commit is contained in:
@@ -1,19 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
README.md
|
||||
docs
|
||||
AGENTS.md
|
||||
*.log
|
||||
.DS_Store
|
||||
.env*
|
||||
!.env.example
|
||||
.vscode
|
||||
.idea
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
dist
|
||||
build
|
||||
@@ -1,5 +1,5 @@
|
||||
# =============================================================================
|
||||
# beenvoice-web — environment template
|
||||
# Beenvoice web workspace — environment template
|
||||
# =============================================================================
|
||||
#
|
||||
# Quick start (local dev):
|
||||
|
||||
+2
-4
@@ -1,11 +1,9 @@
|
||||
# beenvoice - AI Assistant Rules
|
||||
|
||||
> **Canonical architecture reference:** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (stack, routers, schema, auth). This file may lag behind; prefer ARCHITECTURE.md for facts.
|
||||
> **Canonical architecture reference:** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (stack, routers, schema, auth). Root workspace rules are in [../../AGENTS.md](../../AGENTS.md).
|
||||
|
||||
## Project Overview
|
||||
beenvoice-web is the web app and API for beenvoice — Next.js 16, tRPC 11, Drizzle/PostgreSQL, better-auth, and shadcn/ui. Reliability, security, and professional UX are paramount.
|
||||
|
||||
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
|
||||
`apps/web` is the web app and API for Beenvoice — Next.js 16, tRPC 11, Drizzle/PostgreSQL, Better Auth, and shadcn/ui. Reliability, security, and professional UX are paramount.
|
||||
|
||||
## Core Development Principles
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM oven/bun:1 AS base
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
FROM base AS install
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Next.js build must run on Node — Bun 1.3.x can SIGSEGV on Linux arm64 during
|
||||
# the "Collecting page data" worker phase (oven-sh/bun#...). Runtime stays on Bun.
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /usr/src/app
|
||||
COPY --from=install /usr/src/app/node_modules node_modules
|
||||
COPY . .
|
||||
|
||||
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
ARG BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# Low-memory Docker build profile:
|
||||
# - skip tsc inside `next build` (run `bun run check` in CI instead)
|
||||
ENV DOCKER_BUILD=1 \
|
||||
NODE_ENV=production \
|
||||
SKIP_ENV_VALIDATION=1 \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
BETTER_AUTH_URL=${BETTER_AUTH_URL} \
|
||||
NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} \
|
||||
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
||||
RUN node ./node_modules/next/dist/bin/next build
|
||||
|
||||
FROM base AS release
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY --from=build /usr/src/app/.next ./.next
|
||||
COPY --from=build /usr/src/app/public ./public
|
||||
COPY --from=install /usr/src/app/node_modules node_modules
|
||||
COPY --from=build /usr/src/app/package.json ./package.json
|
||||
COPY --from=build /usr/src/app/drizzle.config.ts ./drizzle.config.ts
|
||||
COPY --from=build /usr/src/app/drizzle ./drizzle
|
||||
COPY --from=build /usr/src/app/src/server/db/migrate.ts ./migrate.ts
|
||||
|
||||
RUN chmod -R a+rX drizzle public migrate.ts
|
||||
|
||||
USER bun
|
||||
EXPOSE 3000
|
||||
CMD ["sh", "-c", "bun migrate.ts && bun run start"]
|
||||
+19
-23
@@ -1,12 +1,11 @@
|
||||

|
||||
|
||||
# beenvoice-web
|
||||
# Beenvoice web
|
||||
|
||||
Web application and API for **beenvoice** — invoicing for freelancers and small businesses. Includes the Next.js dashboard, tRPC API, better-auth, PostgreSQL persistence, PDF/email delivery, time tracking, and an MCP automation endpoint.
|
||||
|
||||
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
|
||||
**Architecture:** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
|
||||
**Mobile companion:** [beenvoice-app](https://git.soconnor.dev/soconnor/beenvoice-app) (separate repo; often checked out beside this one in a workspace)
|
||||
**Mobile companion:** [apps/mobile](../mobile/README.md)
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -39,12 +38,12 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal
|
||||
|
||||
## Local development
|
||||
|
||||
### 1. Clone and install
|
||||
### 1. Install the workspace
|
||||
|
||||
```bash
|
||||
git clone https://git.soconnor.dev/soconnor/beenvoice-web.git
|
||||
cd beenvoice-web
|
||||
cd beenvoice
|
||||
bun install
|
||||
cd apps/web
|
||||
```
|
||||
|
||||
### 2. Environment
|
||||
@@ -70,7 +69,7 @@ Email and SSO are optional for local work — leave `RESEND_*` and `AUTHENTIK_*`
|
||||
Start Postgres (dev compose exposes port 5432):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
bun run docker:up
|
||||
```
|
||||
|
||||
After a fresh volume (`docker compose down -v`), Postgres starts empty — you must apply schema before registering or signing in.
|
||||
@@ -104,9 +103,9 @@ Open [http://localhost:3000](http://localhost:3000), register at `/auth/register
|
||||
|
||||
The production compose file runs the Next.js app and PostgreSQL.
|
||||
|
||||
**Container startup** runs `bun migrate.ts && bun run start` (see `Dockerfile`). Drizzle only applies **pending** migrations — safe to run on every restart; already-applied migrations are skipped.
|
||||
**Container startup** runs the web migration script followed by `bun run start` (see the root [`Dockerfile`](../../Dockerfile)). Drizzle only applies **pending** migrations — safe to run on every restart; already-applied migrations are skipped.
|
||||
|
||||
The Docker **build** runs `next build` on **Node 22** (Bun can crash on Linux arm64 during the page-data worker phase). The **runtime** image still uses Bun for migrations and `next start`. `docker-compose.yml` does not set container memory or CPU limits — containers can use whatever the Docker host provides.
|
||||
The Docker **build** runs `next build` on **Node 22** (Bun can crash on Linux arm64 during the page-data worker phase). The **runtime** image still uses Bun for migrations and `next start`. The root `docker-compose.yml` does not set container memory or CPU limits.
|
||||
|
||||
### 1. Configure
|
||||
|
||||
@@ -127,7 +126,7 @@ NEXT_PUBLIC_APP_URL=https://your-public-hostname
|
||||
`NEXT_PUBLIC_*` values are embedded at **image build** time. Rebuild after changing `NEXT_PUBLIC_APP_URL`, white-label defaults, or `NEXT_PUBLIC_AUTHENTIK_ENABLED`:
|
||||
|
||||
```bash
|
||||
docker compose build --no-cache app
|
||||
docker compose -f ../../docker-compose.yml build --no-cache app
|
||||
```
|
||||
|
||||
`BETTER_AUTH_URL` and `AUTH_SECRET` are read at **container runtime** from `.env` — you can change them without rebuilding, then restart the app container.
|
||||
@@ -135,9 +134,9 @@ docker compose build --no-cache app
|
||||
### 2. First start (or after code changes)
|
||||
|
||||
```bash
|
||||
./scripts/docker-deploy.sh
|
||||
../../scripts/docker-deploy.sh
|
||||
# or: bun run docker:deploy
|
||||
# or: docker compose up -d --build
|
||||
# or, from the repository root: docker compose up -d --build
|
||||
```
|
||||
|
||||
`--build` is required after code changes. A plain `docker compose up -d` reuses the existing `beenvoice:local` image and **does not** pick up new code from `git pull`. The deploy script tags the image with the current git SHA (`beenvoice:<sha>`) so each deploy gets a distinct image.
|
||||
@@ -160,7 +159,7 @@ when something calls `POST /api/cron/generate-recurring` with
|
||||
|
||||
```bash
|
||||
git pull
|
||||
./scripts/docker-deploy.sh # recommended: rebuild + tag with git SHA + restart
|
||||
../../scripts/docker-deploy.sh # recommended: rebuild + tag with git SHA + restart
|
||||
# or: docker compose up -d --build
|
||||
```
|
||||
|
||||
@@ -168,7 +167,7 @@ git pull
|
||||
|---------|-----------|-----------------|
|
||||
| `git pull` only | No | No |
|
||||
| `docker compose up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) |
|
||||
| `./scripts/docker-deploy.sh` or `docker compose up -d --build` | Yes | Yes — on app container start |
|
||||
| `../../scripts/docker-deploy.sh` or root `docker compose up -d --build` | Yes | Yes — on app container start |
|
||||
| `docker compose restart app` | No | Yes — migrate runs again (no-op if up to date) |
|
||||
|
||||
Prune old app images occasionally: `docker image prune -f` (or remove specific `beenvoice:*` tags).
|
||||
@@ -177,7 +176,7 @@ To verify migration files match the journal before deploy: `bun run db:verify-jo
|
||||
|
||||
### 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.
|
||||
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 the root [`docker-compose.coolify.yml`](../../docker-compose.coolify.yml) as a single Compose resource.
|
||||
|
||||
### 4. Sign-ups
|
||||
|
||||
@@ -201,21 +200,18 @@ Use the literal strings `true` or `false` (or omit the variable). Do not rely on
|
||||
## Project structure
|
||||
|
||||
```
|
||||
beenvoice-web/
|
||||
apps/web/
|
||||
├── src/app/ # Routes (dashboard, auth, /api/*)
|
||||
├── src/server/api/ # tRPC routers
|
||||
├── src/server/db/ # Drizzle schema, pool, migrate.ts
|
||||
├── src/components/ # UI (ui/, forms/, layout/, branding/)
|
||||
├── src/lib/ # auth, PDF, email, branding helpers
|
||||
├── drizzle/ # SQL migrations
|
||||
├── Dockerfile # Production image (migrate + next start)
|
||||
├── docker-compose.yml # App + Postgres + Garage (deploy)
|
||||
├── docker-compose.coolify.yml # Coolify Compose (app + db + garage)
|
||||
├── docker-compose.coolify-garage.yml # Garage-only for Coolify Application pairing
|
||||
├── docker-compose.dev.yml # Postgres only (local dev)
|
||||
└── docs/ # Architecture and UI guides
|
||||
```
|
||||
|
||||
Workspace configuration, Dockerfiles, Compose files, and the shared `packages/domain` package live at the repository root.
|
||||
|
||||
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for routers, schema, auth flows, and MCP.
|
||||
|
||||
## Scripts
|
||||
@@ -245,7 +241,7 @@ bun run docker:down # stop dev Postgres + colima
|
||||
bun run docker:deploy # production: rebuild app image + docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Full-stack deploy uses `bun run docker:deploy` or `./scripts/docker-deploy.sh` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`.
|
||||
Full-stack deploy uses `bun run docker:deploy` or `../../scripts/docker-deploy.sh` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`.
|
||||
|
||||
## API surface
|
||||
|
||||
@@ -277,4 +273,4 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
MIT — see the root [LICENSE](../../LICENSE).
|
||||
|
||||
-1938
File diff suppressed because it is too large
Load Diff
@@ -1,74 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,124 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,74 +0,0 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||
volumes:
|
||||
- beenvoice_dev_pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
restart: unless-stopped
|
||||
|
||||
# S3-compatible receipt storage for host dev (`bun dev`). API :3900.
|
||||
garage:
|
||||
image: dxflrs/garage:v2.3.0
|
||||
environment:
|
||||
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
|
||||
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
|
||||
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||
configs:
|
||||
- source: garage_config
|
||||
target: /etc/garage.toml
|
||||
volumes:
|
||||
- beenvoice_dev_garage_meta:/var/lib/garage/meta
|
||||
- beenvoice_dev_garage_data:/var/lib/garage/data
|
||||
command: ["/garage", "server", "--single-node", "--default-bucket"]
|
||||
ports:
|
||||
- "${GARAGE_API_PORT:-3900}:3900"
|
||||
healthcheck:
|
||||
test: ["CMD", "/garage", "status"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 15
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
beenvoice_dev_pg_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"
|
||||
@@ -1,123 +0,0 @@
|
||||
# Production stack (app + Postgres + Garage). Local dev Postgres/Garage: docker-compose.dev.yml
|
||||
#
|
||||
# Coolify: deploy docker-compose.coolify.yml as ONE Docker Compose resource (preferred),
|
||||
# or this file. S3_ENDPOINT=http://garage:3900 works only inside a single stack.
|
||||
# Application + separate Garage → docs/COOLIFY.md.
|
||||
#
|
||||
# After git pull, rebuild before starting — a plain `docker compose up -d` reuses
|
||||
# the existing local image and will NOT include new code. Use:
|
||||
# ./scripts/docker-deploy.sh
|
||||
# docker compose up -d --build
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||
# Fixed default tag (beenvoice:local) is reused until you --build. docker-deploy.sh
|
||||
# sets BEENVOICE_IMAGE=beenvoice:<git-sha> so each deploy gets a fresh tag.
|
||||
image: ${BEENVOICE_IMAGE:-beenvoice:local}
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
|
||||
DB_DISABLE_SSL: "true"
|
||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||
NEXT_PUBLIC_APP_URL: ${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:-GK3515373e4c851ebaad366558}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
|
||||
S3_REGION: ${S3_REGION:-garage}
|
||||
ports:
|
||||
- "${WEB_PORT:-${PORT:-3000}}:3000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
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
|
||||
|
||||
# S3-compatible receipt storage (~50–100 MB RAM vs MinIO). API :3900.
|
||||
garage:
|
||||
image: dxflrs/garage:v2.3.0
|
||||
environment:
|
||||
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
|
||||
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
|
||||
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||
configs:
|
||||
- source: garage_config
|
||||
target: /etc/garage.toml
|
||||
volumes:
|
||||
- beenvoice_garage_meta:/var/lib/garage/meta
|
||||
- beenvoice_garage_data:/var/lib/garage/data
|
||||
command: ["/garage", "server", "--single-node", "--default-bucket"]
|
||||
ports:
|
||||
- "${GARAGE_API_PORT:-3900}: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 = "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"
|
||||
@@ -1,8 +1,8 @@
|
||||
# beenvoice-web architecture
|
||||
# Beenvoice web architecture
|
||||
|
||||
Dense reference for the Next.js web application and API. Package manager: **Bun**. Database: **PostgreSQL** via Drizzle ORM.
|
||||
|
||||
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
|
||||
This application is the server and browser workspace in the Beenvoice monorepo.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -144,7 +144,7 @@ Migrations: `bun run db:generate` → `drizzle/`; apply with `db:push` (dev) or
|
||||
|
||||
## Mobile API contract
|
||||
|
||||
The Expo app (`beenvoice-app`) does **not** use API keys. It:
|
||||
The Expo app (`apps/mobile`) does **not** use API keys. It:
|
||||
|
||||
1. Calls the same tRPC endpoints with `Authorization` cookie header from `authClient.getCookie()`.
|
||||
2. Stores session per account in SecureStore via `@better-auth/expo` (`storagePrefix`: `beenvoice:guest` or `beenvoice:auth:{accountId}`).
|
||||
@@ -183,14 +183,14 @@ Validated in `src/env.js`. See `.env.example`.
|
||||
|
||||
| File | Use |
|
||||
|------|-----|
|
||||
| `docker-compose.yml` | Deploy: `app` + `db` (Postgres internal); copy `.env.example` → `.env` |
|
||||
| `docker-compose.dev.yml` | Local dev: Postgres only, port `${POSTGRES_PORT:-5432}` |
|
||||
| Root `docker-compose.yml` | Deploy: `app` + `db` + Garage; use `apps/web/.env` |
|
||||
| Root `docker-compose.dev.yml` | Local dev: Postgres + Garage |
|
||||
|
||||
App image built from `Dockerfile`. Container `CMD`: `bun migrate.ts && bun run start` (migrations then `next start` on port 3000). Docker builds run `next build` on Node 22 (not Bun) to avoid arm64 worker crashes; runtime stays on Bun. Docker builds disable React Compiler and use `experimental.webpackMemoryOptimizations` to reduce peak RAM.
|
||||
The app image is built from the root `Dockerfile`. Container startup runs the web migration script and then `next start` on port 3000. Docker builds run `next build` on Node 22 (not Bun) to avoid arm64 worker crashes; runtime stays on Bun.
|
||||
|
||||
Set `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` to the public hostname before deploy. Rebuild the image when changing `NEXT_PUBLIC_*` build-time vars.
|
||||
|
||||
**Deploy / update:** `git pull && ./scripts/docker-deploy.sh` (or `docker compose up -d --build`). Plain `docker compose up -d` reuses the local `beenvoice:local` image and does not include pulled code. The deploy script tags images as `beenvoice:<git-sha>`.
|
||||
**Deploy / update from the repository root:** `git pull && ./scripts/docker-deploy.sh` (or `docker compose up -d --build`). Plain `docker compose up -d` reuses the local `beenvoice:local` image and does not include pulled code.
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -214,4 +214,4 @@ bun run check # eslint + tsc
|
||||
- [forms-guide.md](./forms-guide.md), [UI_UNIFORMITY_GUIDE.md](./UI_UNIFORMITY_GUIDE.md)
|
||||
- [data-table-responsive-guide.md](./data-table-responsive-guide.md)
|
||||
- [email-features.md](./email-features.md)
|
||||
- Mobile companion: `../beenvoice-app/docs/ARCHITECTURE.md`
|
||||
- Mobile companion: `../../mobile/docs/ARCHITECTURE.md`
|
||||
|
||||
@@ -75,7 +75,7 @@ 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.
|
||||
Deploy the root **[`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`**
|
||||
@@ -84,7 +84,7 @@ Deploy **[`docker-compose.coolify.yml`](../docker-compose.coolify.yml)** as **on
|
||||
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.
|
||||
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)
|
||||
|
||||
@@ -103,9 +103,9 @@ Alternative: [`docker-compose.yml`](../docker-compose.yml) works the same way; `
|
||||
|
||||
| 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) |
|
||||
| [`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.
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# beenvoice-web documentation
|
||||
|
||||
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
|
||||
# Beenvoice web documentation
|
||||
|
||||
## Core
|
||||
|
||||
@@ -26,11 +24,11 @@
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [../../beenvoice-app/docs/ARCHITECTURE.md](../../beenvoice-app/docs/ARCHITECTURE.md) | Expo app architecture |
|
||||
| [../../beenvoice-app/README.md](../../beenvoice-app/README.md) | Mobile setup |
|
||||
| [../../mobile/docs/ARCHITECTURE.md](../../mobile/docs/ARCHITECTURE.md) | Expo app architecture |
|
||||
| [../../mobile/README.md](../../mobile/README.md) | Mobile setup |
|
||||
|
||||
## Workspace
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [../../README.md](../../README.md) | Meta repo layout, full-stack quick start |
|
||||
| [../../../README.md](../../../README.md) | Monorepo layout and full-stack quick start |
|
||||
|
||||
@@ -7,7 +7,9 @@ export default tseslint.config(
|
||||
{
|
||||
ignores: [".next", "scripts/**"],
|
||||
},
|
||||
...nextCoreWebVitals,
|
||||
// The project supplies the type-aware typescript-eslint configs below.
|
||||
// Avoid registering the same plugin again through Next's basic TS preset.
|
||||
...nextCoreWebVitals.filter((config) => config.name !== "next/typescript"),
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
plugins: {
|
||||
|
||||
@@ -9,6 +9,7 @@ const disableReactCompiler = process.env.DISABLE_REACT_COMPILER === "1";
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
transpilePackages: ["@beenvoice/domain"],
|
||||
// React Compiler is helpful in dev/prod but adds compile-time memory pressure in Docker builds.
|
||||
reactCompiler: !disableReactCompiler,
|
||||
productionBrowserSourceMaps: false,
|
||||
|
||||
+90
-89
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "beenvoice",
|
||||
"name": "@beenvoice/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -13,10 +13,10 @@
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:clone": "./scripts/clone-local.sh",
|
||||
"demo:provision": "bun scripts/provision-demo-account.ts",
|
||||
"docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
|
||||
"docker:down": "docker compose -f docker-compose.dev.yml down && colima stop",
|
||||
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
|
||||
"docker:deploy": "./scripts/docker-deploy.sh",
|
||||
"docker:up": "colima start && docker compose -f ../../docker-compose.dev.yml --env-file .env up -d",
|
||||
"docker:down": "docker compose -f ../../docker-compose.dev.yml --env-file .env down && colima stop",
|
||||
"docker:dev:down": "docker compose -f ../../docker-compose.dev.yml --env-file .env down && colima stop",
|
||||
"docker:deploy": "../../scripts/docker-deploy.sh",
|
||||
"deploy": "drizzle-kit push && next build",
|
||||
"dev": "next dev --turbo",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
@@ -28,94 +28,95 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@better-auth/expo": "^1.6.19",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||
"@radix-ui/react-avatar": "^1.1.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.13",
|
||||
"@radix-ui/react-dialog": "^1.1.16",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
||||
"@radix-ui/react-label": "^2.1.9",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.15",
|
||||
"@radix-ui/react-popover": "^1.1.16",
|
||||
"@radix-ui/react-progress": "^1.1.9",
|
||||
"@radix-ui/react-select": "^2.3.0",
|
||||
"@radix-ui/react-separator": "^1.1.9",
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.14",
|
||||
"@radix-ui/react-tooltip": "^1.2.9",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/extension-color": "^3.13.0",
|
||||
"@tiptap/extension-list-item": "^3.13.0",
|
||||
"@tiptap/extension-text-align": "^3.13.0",
|
||||
"@tiptap/extension-text-style": "^3.13.0",
|
||||
"@tiptap/react": "^3.13.0",
|
||||
"@tiptap/starter-kit": "^3.13.0",
|
||||
"@trpc/client": "^11.17.0",
|
||||
"@trpc/react-query": "^11.17.0",
|
||||
"@trpc/server": "^11.17.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.6.16",
|
||||
"chrono-node": "^2.9.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^12.40.0",
|
||||
"fuse.js": "^7.4.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^16.2.12",
|
||||
"@beenvoice/domain": "workspace:*",
|
||||
"@aws-sdk/client-s3": "3.1075.0",
|
||||
"@better-auth/expo": "1.6.19",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@fontsource-variable/playfair-display": "5.2.8",
|
||||
"@radix-ui/react-alert-dialog": "1.1.16",
|
||||
"@radix-ui/react-avatar": "1.1.12",
|
||||
"@radix-ui/react-checkbox": "1.3.4",
|
||||
"@radix-ui/react-collapsible": "1.1.13",
|
||||
"@radix-ui/react-dialog": "1.1.16",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.17",
|
||||
"@radix-ui/react-label": "2.1.9",
|
||||
"@radix-ui/react-navigation-menu": "1.2.15",
|
||||
"@radix-ui/react-popover": "1.1.16",
|
||||
"@radix-ui/react-progress": "1.1.9",
|
||||
"@radix-ui/react-select": "2.3.0",
|
||||
"@radix-ui/react-separator": "1.1.9",
|
||||
"@radix-ui/react-slot": "1.2.5",
|
||||
"@radix-ui/react-switch": "1.3.0",
|
||||
"@radix-ui/react-tabs": "1.1.14",
|
||||
"@radix-ui/react-tooltip": "1.2.9",
|
||||
"@react-pdf/renderer": "4.5.1",
|
||||
"@t3-oss/env-nextjs": "0.12.0",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tiptap/extension-color": "3.22.4",
|
||||
"@tiptap/extension-list-item": "3.22.4",
|
||||
"@tiptap/extension-text-align": "3.22.4",
|
||||
"@tiptap/extension-text-style": "3.22.4",
|
||||
"@tiptap/react": "3.22.4",
|
||||
"@tiptap/starter-kit": "3.22.4",
|
||||
"@trpc/client": "11.17.0",
|
||||
"@trpc/react-query": "11.17.0",
|
||||
"@trpc/server": "11.17.0",
|
||||
"bcryptjs": "3.0.3",
|
||||
"better-auth": "1.6.16",
|
||||
"chrono-node": "2.9.1",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"date-fns": "4.4.0",
|
||||
"dotenv": "17.4.2",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"file-saver": "2.0.5",
|
||||
"framer-motion": "12.40.0",
|
||||
"fuse.js": "7.4.2",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "16.2.12",
|
||||
"pg": "8.21.0",
|
||||
"react": "^19.2.8",
|
||||
"react-colorful": "^5.7.0",
|
||||
"react-day-picker": "^9.12.0",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"recharts": "^3.8.1",
|
||||
"resend": "^4.8.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"sonner": "^2.0.7",
|
||||
"superjson": "^2.2.6",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"trpc": "^0.11.3",
|
||||
"zod": "^3.25.76"
|
||||
"react": "19.2.8",
|
||||
"react-colorful": "5.7.0",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.8",
|
||||
"react-dropzone": "14.4.1",
|
||||
"recharts": "3.8.1",
|
||||
"resend": "4.8.0",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "0.35.3",
|
||||
"sonner": "2.0.7",
|
||||
"superjson": "2.2.6",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"trpc": "0.11.3",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^20.19.26",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/raf": "^3.4.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"baseline-browser-mapping": "^2.10.34",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.2.7",
|
||||
"eslint-plugin-drizzle": "^0.2.3",
|
||||
"postcss": "^8.5.15",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@types/bcryptjs": "2.4.6",
|
||||
"@types/file-saver": "2.0.7",
|
||||
"@types/node": "20.19.39",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/raf": "3.4.3",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"baseline-browser-mapping": "2.10.34",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-config-next": "16.2.7",
|
||||
"eslint-plugin-drizzle": "0.2.3",
|
||||
"postcss": "8.5.15",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.60.1"
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.60.1"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Production deploy helper for docker-compose.yml (not docker-compose.dev.yml).
|
||||
# Rebuilds the app image from the current working tree, then starts/restarts services
|
||||
# (app, db, garage). Receipt storage uses in-stack Garage unless S3_* are
|
||||
# overridden in .env. Garage S3 API: localhost:${GARAGE_API_PORT:-3900}.
|
||||
#
|
||||
# Plain `docker compose up -d` reuses the local image tag and does NOT pick up
|
||||
# changes from `git pull`. Always pass --build or use this script after pulling.
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [[ -f .env ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [[ -z "${BEENVOICE_IMAGE:-}" ]] && command -v git >/dev/null 2>&1; then
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
BEENVOICE_IMAGE="beenvoice:$(git rev-parse --short HEAD)"
|
||||
export BEENVOICE_IMAGE
|
||||
fi
|
||||
fi
|
||||
|
||||
BEENVOICE_IMAGE="${BEENVOICE_IMAGE:-beenvoice:local}"
|
||||
export BEENVOICE_IMAGE
|
||||
|
||||
echo "Deploying ${BEENVOICE_IMAGE} (docker compose up -d --build)..."
|
||||
exec docker compose up -d --build "$@"
|
||||
@@ -1,11 +1,4 @@
|
||||
export const EXPENSE_CATEGORIES = [
|
||||
"Travel",
|
||||
"Meals & Entertainment",
|
||||
"Software & Subscriptions",
|
||||
"Hardware & Equipment",
|
||||
"Office Supplies",
|
||||
"Marketing",
|
||||
"Professional Services",
|
||||
"Utilities",
|
||||
"Other",
|
||||
] as const;
|
||||
export {
|
||||
EXPENSE_CATEGORIES,
|
||||
type ExpenseCategory,
|
||||
} from "@beenvoice/domain/expense-categories";
|
||||
|
||||
@@ -1,78 +1,36 @@
|
||||
import {
|
||||
getDaysPastDue as getSharedDaysPastDue,
|
||||
getEffectiveInvoiceStatus as getSharedEffectiveInvoiceStatus,
|
||||
getValidStatusTransitions as getSharedValidStatusTransitions,
|
||||
isInvoiceOverdue as isSharedInvoiceOverdue,
|
||||
isValidStatusTransition as isSharedValidStatusTransition,
|
||||
} from "@beenvoice/domain/invoice-status";
|
||||
import type {
|
||||
StoredInvoiceStatus,
|
||||
EffectiveInvoiceStatus,
|
||||
StoredInvoiceStatus,
|
||||
} from "~/types/invoice";
|
||||
|
||||
// Types are now imported from ~/types/invoice
|
||||
|
||||
/**
|
||||
* Calculate the effective status of an invoice including overdue computation
|
||||
*/
|
||||
export function getEffectiveInvoiceStatus(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): EffectiveInvoiceStatus {
|
||||
// If already paid, status is always paid regardless of due date
|
||||
if (storedStatus === "paid") {
|
||||
return "paid";
|
||||
}
|
||||
|
||||
// If draft, status is always draft
|
||||
if (storedStatus === "draft") {
|
||||
return "draft";
|
||||
}
|
||||
|
||||
// For sent invoices, check if overdue
|
||||
if (storedStatus === "sent") {
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
|
||||
// Set both dates to start of day for accurate comparison
|
||||
today.setHours(0, 0, 0, 0);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
|
||||
return due < today ? "overdue" : "sent";
|
||||
}
|
||||
|
||||
return storedStatus;
|
||||
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an invoice is overdue
|
||||
*/
|
||||
export function isInvoiceOverdue(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): boolean {
|
||||
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
|
||||
return isSharedInvoiceOverdue(storedStatus, dueDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get days past due (returns 0 if not overdue)
|
||||
*/
|
||||
export function getDaysPastDue(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): number {
|
||||
if (!isInvoiceOverdue(storedStatus, dueDate)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
|
||||
today.setHours(0, 0, 0, 0);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffTime = today.getTime() - due.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return Math.max(0, diffDays);
|
||||
return getSharedDaysPastDue(storedStatus, dueDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status configuration for UI display
|
||||
*/
|
||||
export const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
@@ -96,42 +54,22 @@ export const statusConfig = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get status configuration for display
|
||||
*/
|
||||
export function getStatusConfig(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
) {
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, dueDate);
|
||||
return statusConfig[effectiveStatus];
|
||||
return statusConfig[getEffectiveInvoiceStatus(storedStatus, dueDate)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid status transitions from current stored status
|
||||
*/
|
||||
export function getValidStatusTransitions(
|
||||
currentStatus: StoredInvoiceStatus,
|
||||
): StoredInvoiceStatus[] {
|
||||
switch (currentStatus) {
|
||||
case "draft":
|
||||
return ["sent", "paid"]; // Can send or mark paid directly
|
||||
case "sent":
|
||||
return ["paid", "draft"]; // Can mark paid or revert to draft
|
||||
case "paid":
|
||||
return ["sent"]; // Can revert to sent if needed (rare cases)
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
return getSharedValidStatusTransitions(currentStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status transition is valid
|
||||
*/
|
||||
export function isValidStatusTransition(
|
||||
from: StoredInvoiceStatus,
|
||||
to: StoredInvoiceStatus,
|
||||
): boolean {
|
||||
const validTransitions = getValidStatusTransitions(from);
|
||||
return validTransitions.includes(to);
|
||||
return isSharedValidStatusTransition(from, to);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,4 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
export {
|
||||
parseReceiptText,
|
||||
type ReceiptParseResult,
|
||||
} from "@beenvoice/domain/receipt-parse";
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/** Stored on entries clocked in before empty descriptions were allowed. */
|
||||
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
||||
import { LEGACY_DEFAULT_CLOCK_DESCRIPTION } from "@beenvoice/domain/time-clock";
|
||||
import type { ClockOutOutcome } from "@beenvoice/domain/time-clock";
|
||||
|
||||
export {
|
||||
formatElapsedSeconds,
|
||||
LEGACY_DEFAULT_CLOCK_DESCRIPTION,
|
||||
type ClockOutOutcome,
|
||||
} from "@beenvoice/domain/time-clock";
|
||||
|
||||
export function normalizeOptionalId(value?: string | null): string | null {
|
||||
const trimmed = value?.trim();
|
||||
@@ -44,24 +50,11 @@ export function resolveBillingDescription(description?: string | null): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export type ClockOutOutcome =
|
||||
| "linked_to_invoice"
|
||||
| "saved_no_invoice"
|
||||
| "saved_no_client"
|
||||
| "zero_hours";
|
||||
|
||||
export function computeTrackedHours(startedAt: Date, endedAt: Date): number {
|
||||
const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
|
||||
}
|
||||
|
||||
export function formatElapsedSeconds(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":");
|
||||
}
|
||||
|
||||
export function describeClockOutOutcome(input: {
|
||||
outcome: ClockOutOutcome;
|
||||
hours: number;
|
||||
|
||||
Reference in New Issue
Block a user