Convert Beenvoice to a Turborepo monorepo
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beenvoice-web-dev",
|
"name": "beenvoice-web-dev",
|
||||||
"runtimeExecutable": "bash",
|
"runtimeExecutable": "bash",
|
||||||
"runtimeArgs": ["-c", "cd beenvoice-web && PORT=3010 bun run dev"],
|
"runtimeArgs": ["-c", "cd apps/web && PORT=3010 bun run dev"],
|
||||||
"port": 3010
|
"port": 3010
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
.next
|
||||||
|
**/.next
|
||||||
|
.turbo
|
||||||
|
**/.turbo
|
||||||
|
.expo
|
||||||
|
**/.expo
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*
|
||||||
|
README.md
|
||||||
|
docs
|
||||||
|
AGENTS.md
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
coverage
|
||||||
|
*.tsbuildinfo
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
build
|
||||||
|
apps/mobile/*
|
||||||
|
!apps/mobile/package.json
|
||||||
|
apps/web/store-assets
|
||||||
|
apps/web/tsconfig.tsbuildinfo
|
||||||
|
.claude
|
||||||
|
beenvoice.icon
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Beenvoice repository guidance
|
||||||
|
|
||||||
|
- Use Bun exclusively for dependency installation and workspace scripts.
|
||||||
|
- Run cross-workspace commands from the repository root; use `--filter` for app-specific tasks.
|
||||||
|
- Put platform-neutral rules, serialized types, constants, and parsing in `packages/domain`.
|
||||||
|
- Keep browser-only UI in `apps/web` and React Native/native code in `apps/mobile`.
|
||||||
|
- Do not import server implementations into mobile runtime bundles. The `AppRouter` import is type-only.
|
||||||
|
- Keep Drizzle migrations and their journal together under `apps/web/drizzle`.
|
||||||
|
- Never commit `.env`, signing credentials, native build output, receipts, or customer data.
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM oven/bun:1.3.14 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS install
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
COPY apps/web/package.json apps/web/package.json
|
||||||
|
COPY apps/mobile/package.json apps/mobile/package.json
|
||||||
|
COPY packages/domain/package.json packages/domain/package.json
|
||||||
|
RUN bun install --frozen-lockfile --filter @beenvoice/web
|
||||||
|
|
||||||
|
# Next's production build runs under Node because Bun can fail during the
|
||||||
|
# page-data worker phase on Linux arm64. Dependencies still come from Bun.
|
||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=install /app ./
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
ARG BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
ENV DOCKER_BUILD=1 \
|
||||||
|
DISABLE_REACT_COMPILER=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
|
||||||
|
|
||||||
|
WORKDIR /app/apps/web
|
||||||
|
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=install /app/node_modules ./node_modules
|
||||||
|
COPY --from=install /app/apps/web/node_modules ./apps/web/node_modules
|
||||||
|
COPY --from=build /app/package.json ./package.json
|
||||||
|
COPY --from=build /app/apps/web/package.json ./apps/web/package.json
|
||||||
|
COPY --from=build /app/packages/domain ./packages/domain
|
||||||
|
COPY --from=build /app/apps/web/.next ./apps/web/.next
|
||||||
|
COPY --from=build /app/apps/web/public ./apps/web/public
|
||||||
|
COPY --from=build /app/apps/web/drizzle.config.ts ./apps/web/drizzle.config.ts
|
||||||
|
COPY --from=build /app/apps/web/drizzle ./apps/web/drizzle
|
||||||
|
COPY --from=build /app/apps/web/src/server/db/migrate.ts ./apps/web/src/server/db/migrate.ts
|
||||||
|
|
||||||
|
RUN chmod -R a+rX apps/web/drizzle apps/web/public apps/web/src/server/db/migrate.ts
|
||||||
|
|
||||||
|
USER bun
|
||||||
|
EXPOSE 3000
|
||||||
|
WORKDIR /app/apps/web
|
||||||
|
CMD ["sh", "-c", "bun src/server/db/migrate.ts && bun run start"]
|
||||||
@@ -1,95 +1,81 @@
|
|||||||
# beenvoice workspace
|
# beenvoice
|
||||||
|
|
||||||
Local development layout for the **beenvoice** product: a freelancer/small-business invoicing platform with a Next.js web app and an Expo mobile companion.
|
Beenvoice is a freelancer and small-business invoicing platform with a Next.js web application/API and an Expo mobile companion. The repository is a Bun workspace orchestrated by Turborepo.
|
||||||
|
|
||||||
```
|
## Workspace map
|
||||||
beenvoice-meta/
|
|
||||||
├── beenvoice-web/ # Web API + dashboard (Next.js 16, tRPC, PostgreSQL)
|
```text
|
||||||
├── beenvoice-app/ # Mobile app (Expo, React Native, dev client)
|
beenvoice/
|
||||||
└── beenvoice.icon/ # iOS app icon asset (icon composer)
|
├── apps/
|
||||||
|
│ ├── web/ # Next.js dashboard, tRPC API, PostgreSQL/Drizzle
|
||||||
|
│ └── mobile/ # Expo Router mobile app and iOS widgets
|
||||||
|
├── packages/
|
||||||
|
│ └── domain/ # Platform-neutral shared rules and parsing
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose*.yml
|
||||||
|
├── package.json
|
||||||
|
└── turbo.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Each subdirectory is its own git repository. This folder is a convenience layout for working on both clients against one API.
|
The mobile app consumes the same tRPC API and Better Auth sessions as the web app. It imports the server router type at compile time, while runtime-safe shared behavior lives in `@beenvoice/domain`.
|
||||||
|
|
||||||
| Repo | Remote |
|
## Quick start
|
||||||
|------|--------|
|
|
||||||
| Web | [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web) |
|
|
||||||
| Mobile | [git.soconnor.dev/soconnor/beenvoice-app](https://git.soconnor.dev/soconnor/beenvoice-app) |
|
|
||||||
|
|
||||||
## Quick start (full stack)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. API + database
|
|
||||||
cd beenvoice-web
|
|
||||||
cp .env.example .env.local # edit DATABASE_URL, AUTH_SECRET, etc.
|
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
|
||||||
bun install && bun run db:push
|
|
||||||
bun run dev # http://localhost:3000
|
|
||||||
|
|
||||||
# 2. Mobile (simulator)
|
|
||||||
cd ../beenvoice-app
|
|
||||||
cp .env.example .env # EXPO_PUBLIC_API_URL=http://localhost:3000
|
|
||||||
bun install
|
bun install
|
||||||
bun run ios # Metro on :8082, native dev build
|
|
||||||
|
cp apps/web/.env.example apps/web/.env.local
|
||||||
|
cp apps/mobile/.env.example apps/mobile/.env
|
||||||
|
|
||||||
|
bun run --filter @beenvoice/web docker:up
|
||||||
|
bun run --filter @beenvoice/web db:push
|
||||||
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Physical iPhone: set `EXPO_PUBLIC_API_URL` to your Mac's LAN IP; ensure `beenvoice-web` has `BETTER_AUTH_URL` matching a host the device can reach (trusted origins are derived from `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL`).
|
`bun run dev` starts Next.js on port 3000 and Expo Metro on port 8082. For a physical iPhone, set `EXPO_PUBLIC_API_URL` in `apps/mobile/.env` to a host the device can reach and configure the web app's canonical/auth URLs consistently.
|
||||||
|
|
||||||
## Production deploy (Docker)
|
Useful workspace commands:
|
||||||
|
|
||||||
From `beenvoice-web` with `.env` configured (see [beenvoice-web/README.md](./beenvoice-web/README.md#docker-deployment-app--database)):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd beenvoice-web
|
bun run typecheck
|
||||||
git pull
|
bun run lint
|
||||||
./scripts/docker-deploy.sh # rebuild app image + restart (not docker-compose.dev.yml)
|
bun run test
|
||||||
|
bun run build
|
||||||
|
bun run check
|
||||||
```
|
```
|
||||||
|
|
||||||
Plain `docker compose up -d` without `--build` reuses the existing local image and will not pick up code from `git pull`.
|
Run an app-specific command with a workspace filter:
|
||||||
|
|
||||||
## How the pieces connect
|
```bash
|
||||||
|
bun run --filter @beenvoice/web db:migrate
|
||||||
|
bun run --filter @beenvoice/mobile ios
|
||||||
|
bun run --filter @beenvoice/mobile ios:release:upload
|
||||||
|
```
|
||||||
|
|
||||||
| Layer | Repo | Transport | Auth |
|
## Production Docker deployment
|
||||||
|-------|------|-----------|------|
|
|
||||||
| Web UI | `beenvoice-web` | tRPC `/api/trpc` (cookies) | better-auth session |
|
|
||||||
| Mobile UI | `beenvoice-app` | tRPC `/api/trpc` (cookie header) | better-auth + `@better-auth/expo` → SecureStore |
|
|
||||||
| Automation | `beenvoice-web` MCP `/api/mcp` | JSON-RPC | API key (`bv_…`) only |
|
|
||||||
| Public invoices | `beenvoice-web` `/i/[token]` | HTTP | unauthenticated token |
|
|
||||||
|
|
||||||
Mobile imports **tRPC router types** from `beenvoice-web/src/server/api/root` via `tsconfig` path mapping — keep API changes type-checked in both repos.
|
Keep production web configuration in `apps/web/.env`, then run from the repository root:
|
||||||
|
|
||||||
## Documentation map
|
```bash
|
||||||
|
git pull
|
||||||
|
./scripts/docker-deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
| Doc | Contents |
|
The root Dockerfile installs the frozen Bun workspace lockfile, builds the Next.js app under Node, and runs migrations plus the web server under Bun.
|
||||||
|-----|----------|
|
|
||||||
| [beenvoice-web/README.md](./beenvoice-web/README.md) | Web setup, Docker deploy, scripts |
|
|
||||||
| [beenvoice-web/docs/ARCHITECTURE.md](./beenvoice-web/docs/ARCHITECTURE.md) | Server architecture (dense) |
|
|
||||||
| [beenvoice-app/README.md](./beenvoice-app/README.md) | Mobile setup, troubleshooting |
|
|
||||||
| [beenvoice-app/docs/ARCHITECTURE.md](./beenvoice-app/docs/ARCHITECTURE.md) | Mobile architecture (dense) |
|
|
||||||
| [beenvoice-web/docs/](./beenvoice-web/docs/) | UI guides (forms, tables, breadcrumbs, email) |
|
|
||||||
|
|
||||||
## Shared domain concepts
|
## Documentation
|
||||||
|
|
||||||
- **Clients** — billable contacts with optional default hourly rate
|
- [Web setup](./apps/web/README.md)
|
||||||
- **Businesses** — sender profiles (logo, address, Resend email config)
|
- [Web architecture](./apps/web/docs/ARCHITECTURE.md)
|
||||||
- **Invoices** — draft → sent → paid; line items; PDF + email; public share token
|
- [Mobile setup](./apps/mobile/README.md)
|
||||||
- **Time entries** — clock in/out; one running timer per user; auto-attach to open invoice
|
- [Mobile architecture](./apps/mobile/docs/ARCHITECTURE.md)
|
||||||
- **Recurring invoices**, **expenses**, **payments**, **templates** — full web support; mobile covers a core CRUD subset
|
- [Shared domain package](./packages/domain/README.md)
|
||||||
|
|
||||||
## Environment cheat sheet
|
## Product concepts
|
||||||
|
|
||||||
| Variable | Where | Purpose |
|
- Clients with optional default hourly rates
|
||||||
|----------|-------|---------|
|
- Businesses and sender branding
|
||||||
| `DATABASE_URL` | beenvoice-web | PostgreSQL |
|
- Draft, sent, paid, and overdue invoices
|
||||||
| `AUTH_SECRET`, `BETTER_AUTH_URL` | beenvoice-web | better-auth |
|
- Time tracking and invoice attachment
|
||||||
| `EXPO_PUBLIC_API_URL` | beenvoice-app | API base for mobile |
|
- Recurring invoices, expenses, payments, and templates
|
||||||
| `DISABLE_SIGNUPS` | beenvoice-web | `true` blocks new registrations |
|
- Public invoice links and API-key-authenticated automation
|
||||||
| `RESEND_*` | beenvoice-web | Invoice / reset email |
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
|
|
||||||
| Service | Default port |
|
|
||||||
|---------|----------------|
|
|
||||||
| Next.js (beenvoice-web) | 3000 |
|
|
||||||
| Postgres (dev compose) | 5432 |
|
|
||||||
| Metro (beenvoice-app) | **8082** (intentionally not 8081) |
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# beenvoice-app — agent notes
|
# Beenvoice mobile — agent notes
|
||||||
|
|
||||||
Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) before changing native config.
|
Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) before changing native config.
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@ Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) b
|
|||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Package manager**: Bun only
|
- **Package manager**: Bun only
|
||||||
- **API types**: import `AppRouter` from `beenvoice/server/api/root` (tsconfig path `../beenvoice-web/src/*`)
|
- **API types**: import `AppRouter` from `beenvoice/server/api/root` (tsconfig path `../web/src/*`)
|
||||||
|
- **Shared domain**: import platform-neutral behavior from `@beenvoice/domain`
|
||||||
- **Styling**: `useAppTheme()` + `useThemedStyles()`; tokens in `lib/theme-palette.ts`
|
- **Styling**: `useAppTheme()` + `useThemedStyles()`; tokens in `lib/theme-palette.ts`
|
||||||
- **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`)
|
- **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`)
|
||||||
- **Auth**: never remount account without migrating SecureStore session (`lib/auth-storage.ts`)
|
- **Auth**: never remount account without migrating SecureStore session (`lib/auth-storage.ts`)
|
||||||
@@ -28,6 +29,6 @@ Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) b
|
|||||||
| App lock | `lib/app-lock.ts`, `contexts/AppLockContext.tsx` |
|
| App lock | `lib/app-lock.ts`, `contexts/AppLockContext.tsx` |
|
||||||
| Time clock | `components/time-clock/TimeClockPanel.tsx` |
|
| Time clock | `components/time-clock/TimeClockPanel.tsx` |
|
||||||
|
|
||||||
## Server repo
|
## Server workspace
|
||||||
|
|
||||||
Sibling `../beenvoice-web` — run `bun run dev` on :3000 before mobile dev.
|
Sibling `../web` — run `bun run dev` at the repository root to start both apps.
|
||||||
|
|||||||
+10
-10
@@ -1,20 +1,20 @@
|
|||||||
# beenvoice Mobile
|
# beenvoice Mobile
|
||||||
|
|
||||||
Expo companion for [beenvoice-web](../beenvoice-web) — dashboard, time clock, invoices, clients, businesses, and settings. Shares the **same tRPC API** and **better-auth** sessions as the web app.
|
Expo companion for the [Beenvoice web app](../web/README.md) — dashboard, time clock, invoices, clients, businesses, and settings. Shares the **same tRPC API**, Better Auth sessions, and platform-neutral domain package.
|
||||||
|
|
||||||
**Architecture (dense):** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
|
**Architecture (dense):** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- [Bun](https://bun.sh) 1.3+
|
- [Bun](https://bun.sh) 1.3+
|
||||||
- beenvoice API running ([setup](../beenvoice-web/README.md))
|
- Beenvoice API running ([setup](../web/README.md))
|
||||||
- Xcode + iOS Simulator (or device) for native dev build
|
- Xcode + iOS Simulator (or device) for native dev build
|
||||||
- **Not Expo Go** — widgets, SecureStore auth, and biometrics need `expo-dev-client`
|
- **Not Expo Go** — widgets, SecureStore auth, and biometrics need `expo-dev-client`
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd beenvoice-app
|
cd apps/mobile
|
||||||
bun install
|
bun install
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
@@ -36,11 +36,11 @@ Server must enable `@better-auth/expo` in `beenvoice/src/lib/auth.ts` with `been
|
|||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1 — API
|
# Terminal 1, from the repository root — API + Metro
|
||||||
cd ../beenvoice-web && bun run dev
|
bun run dev
|
||||||
|
|
||||||
# Terminal 2 — mobile (builds native app if needed)
|
# Terminal 2, from the repository root — native app
|
||||||
cd beenvoice-app && bun run ios
|
bun run --filter @beenvoice/mobile ios
|
||||||
```
|
```
|
||||||
|
|
||||||
Metro uses port **8082** (avoids other Expo projects on 8081).
|
Metro uses port **8082** (avoids other Expo projects on 8081).
|
||||||
@@ -145,6 +145,6 @@ widgets/ # iOS Live Activity (TimeClockActivity)
|
|||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [beenvoice-web README](../beenvoice-web/README.md)
|
- [Web README](../web/README.md)
|
||||||
- [beenvoice-web ARCHITECTURE](../beenvoice-web/docs/ARCHITECTURE.md)
|
- [Web architecture](../web/docs/ARCHITECTURE.md)
|
||||||
- [Workspace root README](../README.md)
|
- [Workspace root README](../../README.md)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -247,7 +247,7 @@ Optional: iPad 12.9" if `supportsTablet: true` — use iPad simulator or “Run
|
|||||||
See **[IOS_LOCAL_RELEASE.md](./IOS_LOCAL_RELEASE.md)** for the full guide.
|
See **[IOS_LOCAL_RELEASE.md](./IOS_LOCAL_RELEASE.md)** for the full guide.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd beenvoice-app
|
cd apps/mobile
|
||||||
cp .ios-release.env.example .ios-release.env # once — add Team ID + API key
|
cp .ios-release.env.example .ios-release.env # once — add Team ID + API key
|
||||||
bun run ios:release:upload # archive + upload to TestFlight
|
bun run ios:release:upload # archive + upload to TestFlight
|
||||||
```
|
```
|
||||||
@@ -257,7 +257,7 @@ Requires Xcode on macOS, Apple Developer membership, and an App Store Connect AP
|
|||||||
### Option B — EAS (Expo cloud build)
|
### Option B — EAS (Expo cloud build)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd beenvoice-app
|
cd apps/mobile
|
||||||
|
|
||||||
# Production iOS build (auto-increments build number)
|
# Production iOS build (auto-increments build number)
|
||||||
eas build --platform ios --profile production
|
eas build --platform ios --profile production
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# beenvoice-app architecture
|
# Beenvoice mobile architecture
|
||||||
|
|
||||||
Dense reference for the Expo 57 mobile companion. Talks to **beenvoice** over tRPC + better-auth. Requires a **development build** (not Expo Go) for widgets, SecureStore auth, and biometrics.
|
Dense reference for the Expo 57 mobile companion. Talks to **beenvoice** over tRPC + better-auth. Requires a **development build** (not Expo Go) for widgets, SecureStore auth, and biometrics.
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@ Dense reference for the Expo 57 mobile companion. Talks to **beenvoice** over tR
|
|||||||
| UI | React Native 0.85, `@expo/ui` (SwiftUI widgets) |
|
| UI | React Native 0.85, `@expo/ui` (SwiftUI widgets) |
|
||||||
| API | tRPC 11 + TanStack Query, SuperJSON |
|
| API | tRPC 11 + TanStack Query, SuperJSON |
|
||||||
| Auth | better-auth + `@better-auth/expo` → `expo-secure-store` |
|
| Auth | better-auth + `@better-auth/expo` → `expo-secure-store` |
|
||||||
| Types | `AppRouter` imported from `../beenvoice-web/src/server/api/root` |
|
| Types | `AppRouter` imported type-only from sibling `apps/web` |
|
||||||
|
| Shared domain | `@beenvoice/domain` workspace package |
|
||||||
|
|
||||||
## Boot sequence
|
## Boot sequence
|
||||||
|
|
||||||
@@ -271,6 +272,6 @@ Requires beenvoice with:
|
|||||||
|
|
||||||
- `@better-auth/expo` in `src/lib/auth.ts`
|
- `@better-auth/expo` in `src/lib/auth.ts`
|
||||||
- `trustedOrigins` including `beenvoice://` and `exp://`
|
- `trustedOrigins` including `beenvoice://` and `exp://`
|
||||||
- Postgres running (`docker compose -f docker-compose.dev.yml up -d db`)
|
- Postgres running (`bun run --filter @beenvoice/web docker:up` from the repository root)
|
||||||
|
|
||||||
See [beenvoice-web/docs/ARCHITECTURE.md](../../beenvoice-web/docs/ARCHITECTURE.md).
|
See the [web architecture](../../web/docs/ARCHITECTURE.md).
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Archive and upload **beenvoice** to App Store Connect using Xcode on your Mac
|
|||||||
## One-time setup
|
## One-time setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd beenvoice-app
|
cd apps/mobile
|
||||||
cp .ios-release.env.example .ios-release.env
|
cp .ios-release.env.example .ios-release.env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# beenvoice-app documentation
|
# Beenvoice mobile documentation
|
||||||
|
|
||||||
| Document | Description |
|
| Document | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
@@ -8,5 +8,5 @@
|
|||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [beenvoice-web docs](../../beenvoice-web/docs/README.md) — server API and web app
|
- [Web docs](../../web/docs/README.md) — server API and web app
|
||||||
- [Workspace README](../../README.md) — full-stack layout
|
- [Workspace README](../../../README.md) — full-stack layout
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
export const EXPENSE_CATEGORIES = [
|
export {
|
||||||
"Travel",
|
EXPENSE_CATEGORIES,
|
||||||
"Meals & Entertainment",
|
type ExpenseCategory,
|
||||||
"Software & Subscriptions",
|
} from "@beenvoice/domain/expense-categories";
|
||||||
"Hardware & Equipment",
|
|
||||||
"Office Supplies",
|
|
||||||
"Marketing",
|
|
||||||
"Professional Services",
|
|
||||||
"Utilities",
|
|
||||||
"Other",
|
|
||||||
] as const;
|
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue";
|
import { getEffectiveInvoiceStatus } from "@beenvoice/domain/invoice-status";
|
||||||
|
import type { EffectiveInvoiceStatus } from "@beenvoice/domain/invoice-status";
|
||||||
|
|
||||||
|
export type InvoiceStatus = EffectiveInvoiceStatus;
|
||||||
|
|
||||||
export function getInvoiceStatus(invoice: {
|
export function getInvoiceStatus(invoice: {
|
||||||
status: string;
|
status: string;
|
||||||
dueDate: Date | string;
|
dueDate: Date | string;
|
||||||
}): InvoiceStatus {
|
}): InvoiceStatus {
|
||||||
if (invoice.status === "paid") return "paid";
|
if (invoice.status === "paid" || invoice.status === "draft") {
|
||||||
if (invoice.status === "draft") return "draft";
|
return invoice.status;
|
||||||
|
}
|
||||||
const today = new Date();
|
return getEffectiveInvoiceStatus("sent", invoice.dueDate);
|
||||||
const due = new Date(invoice.dueDate);
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
due.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
if (due < today) return "overdue";
|
|
||||||
return "sent";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const statusLabels: Record<InvoiceStatus, string> = {
|
export const statusLabels: Record<InvoiceStatus, string> = {
|
||||||
|
|||||||
@@ -1,128 +1,5 @@
|
|||||||
export type ReceiptParseResult = {
|
export {
|
||||||
amount: number | null;
|
parseReceiptText,
|
||||||
date: Date | null;
|
type ReceiptLineItem,
|
||||||
subtotal: number | null;
|
type ReceiptParseResult,
|
||||||
tax: number | null;
|
} from "@beenvoice/domain/receipt-parse";
|
||||||
vendor: string | null;
|
|
||||||
items: ReceiptLineItem[];
|
|
||||||
rawLines: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ReceiptLineItem = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
amount: number;
|
|
||||||
rawLine: 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})/,
|
|
||||||
];
|
|
||||||
|
|
||||||
const SUBTOTAL_PATTERNS = [
|
|
||||||
/(?:sub\s?total|subtotal)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
const TAX_PATTERNS = [
|
|
||||||
/(?:tax|sales tax|hst|gst|pst|vat)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
const NON_ITEM_LINE =
|
|
||||||
/(?:total|subtotal|sub total|tax|tip|gratuity|change|cash|visa|mastercard|amex|discover|card|credit|debit|balance|amount due|auth|approval|terminal|merchant|receipt|order|invoice|thank|powered by)/i;
|
|
||||||
|
|
||||||
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 parseFirstMatchingAmount(
|
|
||||||
text: string,
|
|
||||||
patterns: RegExp[],
|
|
||||||
): number | null {
|
|
||||||
for (const pattern of 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;
|
|
||||||
}
|
|
||||||
return 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseLineItems(lines: string[]): ReceiptLineItem[] {
|
|
||||||
const items: ReceiptLineItem[] = [];
|
|
||||||
|
|
||||||
for (const [index, rawLine] of lines.entries()) {
|
|
||||||
const line = rawLine.replace(/\s+/g, " ").trim();
|
|
||||||
if (line.length < 5 || NON_ITEM_LINE.test(line)) continue;
|
|
||||||
|
|
||||||
const match = line.match(/^(.{2,}?)\s+\$?(-?[\d,]+\.\d{2})$/);
|
|
||||||
if (!match?.[1] || !match[2]) continue;
|
|
||||||
|
|
||||||
const amount = Number(match[2].replace(/,/g, ""));
|
|
||||||
const name = match[1]
|
|
||||||
.replace(/^\d+\s*[xX]\s+/, "")
|
|
||||||
.replace(/\s+\d+\s*[xX]\s*$/, "")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if (!Number.isFinite(amount) || amount <= 0 || name.length < 2) continue;
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
id: `${index}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${amount.toFixed(2)}`,
|
|
||||||
name: name.slice(0, 80),
|
|
||||||
amount,
|
|
||||||
rawLine,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.slice(0, 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
subtotal: parseFirstMatchingAmount(normalized, SUBTOTAL_PATTERNS),
|
|
||||||
tax: parseFirstMatchingAmount(normalized, TAX_PATTERNS),
|
|
||||||
vendor: parseVendor(rawLines),
|
|
||||||
items: parseLineItems(rawLines),
|
|
||||||
rawLines,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
export type ClockOutOutcome =
|
import {
|
||||||
| "linked_to_invoice"
|
DEFAULT_CLOCK_DESCRIPTION,
|
||||||
| "saved_no_invoice"
|
LEGACY_DEFAULT_CLOCK_DESCRIPTION,
|
||||||
| "saved_no_client"
|
} from "@beenvoice/domain/time-clock";
|
||||||
| "zero_hours";
|
import type { ClockOutOutcome } from "@beenvoice/domain/time-clock";
|
||||||
|
|
||||||
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In";
|
export {
|
||||||
|
DEFAULT_CLOCK_DESCRIPTION,
|
||||||
/** Stored on entries clocked in before empty descriptions were allowed. */
|
formatElapsedHoursMinutes,
|
||||||
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
formatElapsedSeconds,
|
||||||
|
LEGACY_DEFAULT_CLOCK_DESCRIPTION,
|
||||||
|
type ClockOutOutcome,
|
||||||
|
} from "@beenvoice/domain/time-clock";
|
||||||
|
|
||||||
export function resolveClockDescription(description: string | null | undefined): string {
|
export function resolveClockDescription(description: string | null | undefined): string {
|
||||||
const trimmed = description?.trim();
|
const trimmed = description?.trim();
|
||||||
@@ -26,20 +29,6 @@ export function formatRunningTimerLabel(description?: string | null): string {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
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(":");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hours and minutes only — for Live Activity / compact displays. */
|
|
||||||
export function formatElapsedHoursMinutes(seconds: number): string {
|
|
||||||
const h = Math.floor(seconds / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
return `${h}:${String(m).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveEffectiveHourlyRate(
|
export function resolveEffectiveHourlyRate(
|
||||||
rateText: string,
|
rateText: string,
|
||||||
clientDefaultRate?: number | null,
|
clientDefaultRate?: number | null,
|
||||||
|
|||||||
+52
-42
@@ -1,67 +1,77 @@
|
|||||||
{
|
{
|
||||||
"name": "beenvoice-app",
|
"name": "@beenvoice/mobile",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/expo": "^1.6.19",
|
"@beenvoice/domain": "workspace:*",
|
||||||
"@expo-google-fonts/inter": "^0.4.2",
|
"@better-auth/expo": "1.6.19",
|
||||||
"@expo-google-fonts/playfair-display": "^0.4.2",
|
"@expo-google-fonts/inter": "0.4.2",
|
||||||
"@expo/ui": "~57.0.11",
|
"@expo-google-fonts/playfair-display": "0.4.2",
|
||||||
"@expo/vector-icons": "^15.1.1",
|
"@expo/ui": "57.0.11",
|
||||||
|
"@expo/vector-icons": "15.1.1",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-native-community/datetimepicker": "9.1.0",
|
"@react-native-community/datetimepicker": "9.1.0",
|
||||||
"@react-native-picker/picker": "^2.11.4",
|
"@react-native-picker/picker": "2.11.4",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "5.101.0",
|
||||||
"@trpc/client": "^11.17.0",
|
"@trpc/client": "11.17.0",
|
||||||
"@trpc/react-query": "^11.17.0",
|
"@trpc/react-query": "11.17.0",
|
||||||
"better-auth": "^1.6.19",
|
"@trpc/server": "11.17.0",
|
||||||
"expo": "^57.0.9",
|
"better-auth": "1.6.19",
|
||||||
"expo-blur": "~57.0.2",
|
"expo": "57.0.13",
|
||||||
"expo-build-properties": "~57.0.11",
|
"expo-blur": "57.0.2",
|
||||||
"expo-constants": "~57.0.11",
|
"expo-build-properties": "57.0.11",
|
||||||
"expo-dev-client": "~57.0.12",
|
"expo-constants": "57.0.11",
|
||||||
"expo-file-system": "~57.0.4",
|
"expo-dev-client": "57.0.12",
|
||||||
"expo-font": "~57.0.1",
|
"expo-file-system": "57.0.4",
|
||||||
"expo-image": "~57.0.3",
|
"expo-font": "57.0.1",
|
||||||
"expo-image-picker": "~57.0.10",
|
"expo-image": "57.0.3",
|
||||||
"expo-linear-gradient": "~57.0.1",
|
"expo-image-picker": "57.0.10",
|
||||||
"expo-linking": "~57.0.6",
|
"expo-linear-gradient": "57.0.1",
|
||||||
"expo-local-authentication": "~57.0.2",
|
"expo-linking": "57.0.6",
|
||||||
"expo-mlkit-ocr": "^0.2.7",
|
"expo-local-authentication": "57.0.2",
|
||||||
"expo-network": "~57.0.1",
|
"expo-mlkit-ocr": "0.2.7",
|
||||||
"expo-notifications": "~57.0.11",
|
"expo-modules-core": "57.0.11",
|
||||||
"expo-router": "~57.0.13",
|
"expo-network": "57.0.1",
|
||||||
"expo-secure-store": "~57.0.1",
|
"expo-notifications": "57.0.11",
|
||||||
"expo-sharing": "~57.0.12",
|
"expo-router": "57.0.13",
|
||||||
"expo-splash-screen": "~57.0.6",
|
"expo-secure-store": "57.0.1",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-sharing": "57.0.12",
|
||||||
"expo-symbols": "~57.0.2",
|
"expo-splash-screen": "57.0.6",
|
||||||
"expo-web-browser": "~57.0.2",
|
"expo-status-bar": "57.0.1",
|
||||||
"expo-widgets": "~57.0.10",
|
"expo-symbols": "57.0.2",
|
||||||
|
"expo-web-browser": "57.0.2",
|
||||||
|
"expo-widgets": "57.0.10",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-native": "0.86.2",
|
"react-native": "0.86.2",
|
||||||
|
"react-native-gesture-handler": "3.2.1",
|
||||||
"react-native-reanimated": "4.5.1",
|
"react-native-reanimated": "4.5.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "5.7.0",
|
||||||
"react-native-screens": "~4.26.0",
|
"react-native-screens": "4.26.2",
|
||||||
"react-native-svg": "15.15.4",
|
"react-native-svg": "15.15.4",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "0.21.2",
|
||||||
"react-native-webview": "13.16.1",
|
"react-native-webview": "13.16.1",
|
||||||
"react-native-worklets": "0.10.1",
|
"react-native-worklets": "0.10.1",
|
||||||
"superjson": "^2.2.6"
|
"superjson": "2.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@expo/config-plugins": "57.0.8",
|
||||||
"@types/react": "~19.2.2",
|
"@types/bun": "1.3.14",
|
||||||
"react-native-svg-transformer": "^1.5.3",
|
"@types/react": "19.2.17",
|
||||||
"typescript": "~6.0.3"
|
"react-native-svg-transformer": "1.5.3",
|
||||||
|
"typescript": "6.0.3",
|
||||||
|
"xcode": "3.0.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev": "expo start --dev-client --port 8082",
|
||||||
"start": "expo start --dev-client --port 8082",
|
"start": "expo start --dev-client --port 8082",
|
||||||
"android": "expo run:android --port 8082",
|
"android": "expo run:android --port 8082",
|
||||||
"ios": "expo run:ios --port 8082",
|
"ios": "expo run:ios --port 8082",
|
||||||
"ios:release": "bash scripts/ios-release.sh",
|
"ios:release": "bash scripts/ios-release.sh",
|
||||||
"ios:release:upload": "bash scripts/ios-release.sh --upload",
|
"ios:release:upload": "bash scripts/ios-release.sh --upload",
|
||||||
|
"build": "tsc --noEmit",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
/// <reference types="bun" />
|
/// <reference types="bun" />
|
||||||
|
|
||||||
import { afterEach, describe, expect, test } from "bun:test";
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
EXPENSE_CATEGORIES as domainExpenseCategories,
|
||||||
|
formatElapsedSeconds as formatDomainElapsedSeconds,
|
||||||
|
getEffectiveInvoiceStatus,
|
||||||
|
} from "@beenvoice/domain";
|
||||||
|
|
||||||
import { fetchAuthCapabilities } from "../lib/auth-capabilities";
|
import { fetchAuthCapabilities } from "../lib/auth-capabilities";
|
||||||
import { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories";
|
import { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories";
|
||||||
import { getInvoiceStatus } from "../lib/invoice-status";
|
import { getInvoiceStatus } from "../lib/invoice-status";
|
||||||
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
|
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
|
||||||
import { EXPENSE_CATEGORIES as webExpenseCategories } from "../../beenvoice-web/src/lib/expense-categories";
|
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
|
||||||
import { getEffectiveInvoiceStatus } from "../../beenvoice-web/src/lib/invoice-status";
|
|
||||||
import { safeCallbackPath } from "../../beenvoice-web/src/lib/safe-callback-url";
|
|
||||||
import {
|
import {
|
||||||
formatElapsedSeconds as formatWebElapsedSeconds,
|
|
||||||
normalizeOptionalId,
|
normalizeOptionalId,
|
||||||
} from "../../beenvoice-web/src/lib/time-clock";
|
} from "../../web/src/lib/time-clock";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ describe("timer parity", () => {
|
|||||||
test("elapsed time formatting is identical", () => {
|
test("elapsed time formatting is identical", () => {
|
||||||
for (const seconds of [0, 59, 60, 3_661, 86_399]) {
|
for (const seconds of [0, 59, 60, 3_661, 86_399]) {
|
||||||
expect(formatAppElapsedSeconds(seconds)).toBe(
|
expect(formatAppElapsedSeconds(seconds)).toBe(
|
||||||
formatWebElapsedSeconds(seconds),
|
formatDomainElapsedSeconds(seconds),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -99,6 +101,6 @@ describe("timer parity", () => {
|
|||||||
|
|
||||||
describe("expense parity", () => {
|
describe("expense parity", () => {
|
||||||
test("web and mobile expose the same category vocabulary", () => {
|
test("web and mobile expose the same category vocabulary", () => {
|
||||||
expect([...appExpenseCategories]).toEqual([...webExpenseCategories]);
|
expect([...appExpenseCategories]).toEqual([...domainExpenseCategories]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"],
|
"@/*": ["./*"],
|
||||||
"~/*": ["../beenvoice-web/src/*"],
|
"~/*": ["../web/src/*"],
|
||||||
"src/*": ["../beenvoice-web/src/*"],
|
"src/*": ["../web/src/*"],
|
||||||
"beenvoice/*": ["../beenvoice-web/src/*"]
|
"beenvoice/*": ["../web/src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
@@ -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):
|
# Quick start (local dev):
|
||||||
|
|||||||
+2
-4
@@ -1,11 +1,9 @@
|
|||||||
# beenvoice - AI Assistant Rules
|
# 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
|
## 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.
|
`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.
|
||||||
|
|
||||||
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
|
|
||||||
|
|
||||||
## Core Development Principles
|
## 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.
|
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)
|
**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
|
## Stack
|
||||||
|
|
||||||
@@ -39,12 +38,12 @@ Web application and API for **beenvoice** — invoicing for freelancers and smal
|
|||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
### 1. Clone and install
|
### 1. Install the workspace
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.soconnor.dev/soconnor/beenvoice-web.git
|
cd beenvoice
|
||||||
cd beenvoice-web
|
|
||||||
bun install
|
bun install
|
||||||
|
cd apps/web
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Environment
|
### 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):
|
Start Postgres (dev compose exposes port 5432):
|
||||||
|
|
||||||
```bash
|
```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.
|
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.
|
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
|
### 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`:
|
`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
|
```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.
|
`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)
|
### 2. First start (or after code changes)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/docker-deploy.sh
|
../../scripts/docker-deploy.sh
|
||||||
# or: bun run docker:deploy
|
# 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.
|
`--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
|
```bash
|
||||||
git pull
|
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
|
# or: docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -168,7 +167,7 @@ git pull
|
|||||||
|---------|-----------|-----------------|
|
|---------|-----------|-----------------|
|
||||||
| `git pull` only | No | No |
|
| `git pull` only | No | No |
|
||||||
| `docker compose up -d` (no `--build`) | No — reuses `beenvoice:local` | Only if the app container restarts (same image) |
|
| `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) |
|
| `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).
|
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
|
### 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
|
### 4. Sign-ups
|
||||||
|
|
||||||
@@ -201,21 +200,18 @@ Use the literal strings `true` or `false` (or omit the variable). Do not rely on
|
|||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
beenvoice-web/
|
apps/web/
|
||||||
├── src/app/ # Routes (dashboard, auth, /api/*)
|
├── src/app/ # Routes (dashboard, auth, /api/*)
|
||||||
├── src/server/api/ # tRPC routers
|
├── src/server/api/ # tRPC routers
|
||||||
├── src/server/db/ # Drizzle schema, pool, migrate.ts
|
├── src/server/db/ # Drizzle schema, pool, migrate.ts
|
||||||
├── src/components/ # UI (ui/, forms/, layout/, branding/)
|
├── src/components/ # UI (ui/, forms/, layout/, branding/)
|
||||||
├── 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)
|
|
||||||
├── 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
|
└── 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.
|
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for routers, schema, auth flows, and MCP.
|
||||||
|
|
||||||
## Scripts
|
## 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
|
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
|
## API surface
|
||||||
|
|
||||||
@@ -277,4 +273,4 @@ Business logic lives in `src/server/api/routers/` with Zod validation.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see the root [LICENSE](../../LICENSE).
|
||||||
|
|||||||
-1938
File diff suppressed because it is too large
Load Diff
@@ -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.
|
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
|
## Stack
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ Migrations: `bun run db:generate` → `drizzle/`; apply with `db:push` (dev) or
|
|||||||
|
|
||||||
## Mobile API contract
|
## 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()`.
|
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}`).
|
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 |
|
| File | Use |
|
||||||
|------|-----|
|
|------|-----|
|
||||||
| `docker-compose.yml` | Deploy: `app` + `db` (Postgres internal); copy `.env.example` → `.env` |
|
| Root `docker-compose.yml` | Deploy: `app` + `db` + Garage; use `apps/web/.env` |
|
||||||
| `docker-compose.dev.yml` | Local dev: Postgres only, port `${POSTGRES_PORT:-5432}` |
|
| 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.
|
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
|
## Scripts
|
||||||
|
|
||||||
@@ -214,4 +214,4 @@ bun run check # eslint + tsc
|
|||||||
- [forms-guide.md](./forms-guide.md), [UI_UNIFORMITY_GUIDE.md](./UI_UNIFORMITY_GUIDE.md)
|
- [forms-guide.md](./forms-guide.md), [UI_UNIFORMITY_GUIDE.md](./UI_UNIFORMITY_GUIDE.md)
|
||||||
- [data-table-responsive-guide.md](./data-table-responsive-guide.md)
|
- [data-table-responsive-guide.md](./data-table-responsive-guide.md)
|
||||||
- [email-features.md](./email-features.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
|
## 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**
|
1. Coolify → **New Resource** → **Docker Compose**
|
||||||
2. Point at this repo; compose file: **`docker-compose.coolify.yml`**
|
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.
|
5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://garage:3900` on the shared network.
|
||||||
6. Redeploy.
|
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)
|
### 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 |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| [`docker-compose.coolify.yml`](../docker-compose.coolify.yml) | **Recommended** — full stack for one Coolify Compose resource |
|
| [`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.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-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.
|
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
|
# Beenvoice web documentation
|
||||||
|
|
||||||
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
|
|
||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
@@ -26,11 +24,11 @@
|
|||||||
|
|
||||||
| Document | Description |
|
| Document | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| [../../beenvoice-app/docs/ARCHITECTURE.md](../../beenvoice-app/docs/ARCHITECTURE.md) | Expo app architecture |
|
| [../../mobile/docs/ARCHITECTURE.md](../../mobile/docs/ARCHITECTURE.md) | Expo app architecture |
|
||||||
| [../../beenvoice-app/README.md](../../beenvoice-app/README.md) | Mobile setup |
|
| [../../mobile/README.md](../../mobile/README.md) | Mobile setup |
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
|
|
||||||
| Document | Description |
|
| 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/**"],
|
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"],
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const disableReactCompiler = process.env.DISABLE_REACT_COMPILER === "1";
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
transpilePackages: ["@beenvoice/domain"],
|
||||||
// React Compiler is helpful in dev/prod but adds compile-time memory pressure in Docker builds.
|
// React Compiler is helpful in dev/prod but adds compile-time memory pressure in Docker builds.
|
||||||
reactCompiler: !disableReactCompiler,
|
reactCompiler: !disableReactCompiler,
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
|
|||||||
+90
-89
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "beenvoice",
|
"name": "@beenvoice/web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:clone": "./scripts/clone-local.sh",
|
"db:clone": "./scripts/clone-local.sh",
|
||||||
"demo:provision": "bun scripts/provision-demo-account.ts",
|
"demo:provision": "bun scripts/provision-demo-account.ts",
|
||||||
"docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
|
"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 down && colima stop",
|
"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 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",
|
"docker:deploy": "../../scripts/docker-deploy.sh",
|
||||||
"deploy": "drizzle-kit push && next build",
|
"deploy": "drizzle-kit push && next build",
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev --turbo",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
@@ -28,94 +28,95 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1075.0",
|
"@beenvoice/domain": "workspace:*",
|
||||||
"@better-auth/expo": "^1.6.19",
|
"@aws-sdk/client-s3": "3.1075.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@better-auth/expo": "1.6.19",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/modifiers": "9.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
"@fontsource-variable/playfair-display": "5.2.8",
|
||||||
"@radix-ui/react-avatar": "^1.1.12",
|
"@radix-ui/react-alert-dialog": "1.1.16",
|
||||||
"@radix-ui/react-checkbox": "^1.3.4",
|
"@radix-ui/react-avatar": "1.1.12",
|
||||||
"@radix-ui/react-collapsible": "^1.1.13",
|
"@radix-ui/react-checkbox": "1.3.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.16",
|
"@radix-ui/react-collapsible": "1.1.13",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
"@radix-ui/react-dialog": "1.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.9",
|
"@radix-ui/react-dropdown-menu": "2.1.17",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.15",
|
"@radix-ui/react-label": "2.1.9",
|
||||||
"@radix-ui/react-popover": "^1.1.16",
|
"@radix-ui/react-navigation-menu": "1.2.15",
|
||||||
"@radix-ui/react-progress": "^1.1.9",
|
"@radix-ui/react-popover": "1.1.16",
|
||||||
"@radix-ui/react-select": "^2.3.0",
|
"@radix-ui/react-progress": "1.1.9",
|
||||||
"@radix-ui/react-separator": "^1.1.9",
|
"@radix-ui/react-select": "2.3.0",
|
||||||
"@radix-ui/react-slot": "^1.2.5",
|
"@radix-ui/react-separator": "1.1.9",
|
||||||
"@radix-ui/react-switch": "^1.3.0",
|
"@radix-ui/react-slot": "1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-switch": "1.3.0",
|
||||||
"@radix-ui/react-tooltip": "^1.2.9",
|
"@radix-ui/react-tabs": "1.1.14",
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
"@radix-ui/react-tooltip": "1.2.9",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@react-pdf/renderer": "4.5.1",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@t3-oss/env-nextjs": "0.12.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-query": "5.101.0",
|
||||||
"@tiptap/extension-color": "^3.13.0",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@tiptap/extension-list-item": "^3.13.0",
|
"@tiptap/extension-color": "3.22.4",
|
||||||
"@tiptap/extension-text-align": "^3.13.0",
|
"@tiptap/extension-list-item": "3.22.4",
|
||||||
"@tiptap/extension-text-style": "^3.13.0",
|
"@tiptap/extension-text-align": "3.22.4",
|
||||||
"@tiptap/react": "^3.13.0",
|
"@tiptap/extension-text-style": "3.22.4",
|
||||||
"@tiptap/starter-kit": "^3.13.0",
|
"@tiptap/react": "3.22.4",
|
||||||
"@trpc/client": "^11.17.0",
|
"@tiptap/starter-kit": "3.22.4",
|
||||||
"@trpc/react-query": "^11.17.0",
|
"@trpc/client": "11.17.0",
|
||||||
"@trpc/server": "^11.17.0",
|
"@trpc/react-query": "11.17.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"@trpc/server": "11.17.0",
|
||||||
"better-auth": "^1.6.16",
|
"bcryptjs": "3.0.3",
|
||||||
"chrono-node": "^2.9.1",
|
"better-auth": "1.6.16",
|
||||||
"class-variance-authority": "^0.7.1",
|
"chrono-node": "2.9.1",
|
||||||
"clsx": "^2.1.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"date-fns": "^4.4.0",
|
"clsx": "2.1.1",
|
||||||
"dotenv": "^17.4.2",
|
"date-fns": "4.4.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"dotenv": "17.4.2",
|
||||||
"file-saver": "^2.0.5",
|
"drizzle-orm": "0.45.2",
|
||||||
"framer-motion": "^12.40.0",
|
"file-saver": "2.0.5",
|
||||||
"fuse.js": "^7.4.2",
|
"framer-motion": "12.40.0",
|
||||||
"lucide-react": "^0.525.0",
|
"fuse.js": "7.4.2",
|
||||||
"next": "^16.2.12",
|
"lucide-react": "0.525.0",
|
||||||
|
"next": "16.2.12",
|
||||||
"pg": "8.21.0",
|
"pg": "8.21.0",
|
||||||
"react": "^19.2.8",
|
"react": "19.2.8",
|
||||||
"react-colorful": "^5.7.0",
|
"react-colorful": "5.7.0",
|
||||||
"react-day-picker": "^9.12.0",
|
"react-day-picker": "9.14.0",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "14.4.1",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "3.8.1",
|
||||||
"resend": "^4.8.0",
|
"resend": "4.8.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "0.35.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "2.0.7",
|
||||||
"superjson": "^2.2.6",
|
"superjson": "2.2.6",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"trpc": "^0.11.3",
|
"trpc": "0.11.3",
|
||||||
"zod": "^3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "4.3.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "2.4.6",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/node": "^20.19.26",
|
"@types/node": "20.19.39",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "8.20.0",
|
||||||
"@types/raf": "^3.4.3",
|
"@types/raf": "3.4.3",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"baseline-browser-mapping": "^2.10.34",
|
"baseline-browser-mapping": "2.10.34",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "9.39.4",
|
||||||
"eslint-config-next": "^16.2.7",
|
"eslint-config-next": "16.2.7",
|
||||||
"eslint-plugin-drizzle": "^0.2.3",
|
"eslint-plugin-drizzle": "0.2.3",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "8.5.15",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "0.6.14",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "4.3.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "1.0.7",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "^8.60.1"
|
"typescript-eslint": "8.60.1"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.39.3"
|
"initVersion": "7.39.3"
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
export const EXPENSE_CATEGORIES = [
|
export {
|
||||||
"Travel",
|
EXPENSE_CATEGORIES,
|
||||||
"Meals & Entertainment",
|
type ExpenseCategory,
|
||||||
"Software & Subscriptions",
|
} from "@beenvoice/domain/expense-categories";
|
||||||
"Hardware & Equipment",
|
|
||||||
"Office Supplies",
|
|
||||||
"Marketing",
|
|
||||||
"Professional Services",
|
|
||||||
"Utilities",
|
|
||||||
"Other",
|
|
||||||
] as const;
|
|
||||||
|
|||||||
@@ -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 {
|
import type {
|
||||||
StoredInvoiceStatus,
|
|
||||||
EffectiveInvoiceStatus,
|
EffectiveInvoiceStatus,
|
||||||
|
StoredInvoiceStatus,
|
||||||
} from "~/types/invoice";
|
} from "~/types/invoice";
|
||||||
|
|
||||||
// Types are now imported from ~/types/invoice
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the effective status of an invoice including overdue computation
|
|
||||||
*/
|
|
||||||
export function getEffectiveInvoiceStatus(
|
export function getEffectiveInvoiceStatus(
|
||||||
storedStatus: StoredInvoiceStatus,
|
storedStatus: StoredInvoiceStatus,
|
||||||
dueDate: Date | string,
|
dueDate: Date | string,
|
||||||
): EffectiveInvoiceStatus {
|
): EffectiveInvoiceStatus {
|
||||||
// If already paid, status is always paid regardless of due date
|
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an invoice is overdue
|
|
||||||
*/
|
|
||||||
export function isInvoiceOverdue(
|
export function isInvoiceOverdue(
|
||||||
storedStatus: StoredInvoiceStatus,
|
storedStatus: StoredInvoiceStatus,
|
||||||
dueDate: Date | string,
|
dueDate: Date | string,
|
||||||
): boolean {
|
): boolean {
|
||||||
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
|
return isSharedInvoiceOverdue(storedStatus, dueDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get days past due (returns 0 if not overdue)
|
|
||||||
*/
|
|
||||||
export function getDaysPastDue(
|
export function getDaysPastDue(
|
||||||
storedStatus: StoredInvoiceStatus,
|
storedStatus: StoredInvoiceStatus,
|
||||||
dueDate: Date | string,
|
dueDate: Date | string,
|
||||||
): number {
|
): number {
|
||||||
if (!isInvoiceOverdue(storedStatus, dueDate)) {
|
return getSharedDaysPastDue(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Status configuration for UI display
|
|
||||||
*/
|
|
||||||
export const statusConfig = {
|
export const statusConfig = {
|
||||||
draft: {
|
draft: {
|
||||||
label: "Draft",
|
label: "Draft",
|
||||||
@@ -96,42 +54,22 @@ export const statusConfig = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get status configuration for display
|
|
||||||
*/
|
|
||||||
export function getStatusConfig(
|
export function getStatusConfig(
|
||||||
storedStatus: StoredInvoiceStatus,
|
storedStatus: StoredInvoiceStatus,
|
||||||
dueDate: Date | string,
|
dueDate: Date | string,
|
||||||
) {
|
) {
|
||||||
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, dueDate);
|
return statusConfig[getEffectiveInvoiceStatus(storedStatus, dueDate)];
|
||||||
return statusConfig[effectiveStatus];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get valid status transitions from current stored status
|
|
||||||
*/
|
|
||||||
export function getValidStatusTransitions(
|
export function getValidStatusTransitions(
|
||||||
currentStatus: StoredInvoiceStatus,
|
currentStatus: StoredInvoiceStatus,
|
||||||
): StoredInvoiceStatus[] {
|
): StoredInvoiceStatus[] {
|
||||||
switch (currentStatus) {
|
return getSharedValidStatusTransitions(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 [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a status transition is valid
|
|
||||||
*/
|
|
||||||
export function isValidStatusTransition(
|
export function isValidStatusTransition(
|
||||||
from: StoredInvoiceStatus,
|
from: StoredInvoiceStatus,
|
||||||
to: StoredInvoiceStatus,
|
to: StoredInvoiceStatus,
|
||||||
): boolean {
|
): boolean {
|
||||||
const validTransitions = getValidStatusTransitions(from);
|
return isSharedValidStatusTransition(from, to);
|
||||||
return validTransitions.includes(to);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,4 @@
|
|||||||
export type ReceiptParseResult = {
|
export {
|
||||||
amount: number | null;
|
parseReceiptText,
|
||||||
date: Date | null;
|
type ReceiptParseResult,
|
||||||
vendor: string | null;
|
} from "@beenvoice/domain/receipt-parse";
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
/** Stored on entries clocked in before empty descriptions were allowed. */
|
import { LEGACY_DEFAULT_CLOCK_DESCRIPTION } from "@beenvoice/domain/time-clock";
|
||||||
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
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 {
|
export function normalizeOptionalId(value?: string | null): string | null {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
@@ -44,24 +50,11 @@ export function resolveBillingDescription(description?: string | null): string {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClockOutOutcome =
|
|
||||||
| "linked_to_invoice"
|
|
||||||
| "saved_no_invoice"
|
|
||||||
| "saved_no_client"
|
|
||||||
| "zero_hours";
|
|
||||||
|
|
||||||
export function computeTrackedHours(startedAt: Date, endedAt: Date): number {
|
export function computeTrackedHours(startedAt: Date, endedAt: Date): number {
|
||||||
const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||||
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
|
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: {
|
export function describeClockOutOutcome(input: {
|
||||||
outcome: ClockOutOutcome;
|
outcome: ClockOutOutcome;
|
||||||
hours: number;
|
hours: number;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "beenvoice",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "bun@1.3.14",
|
||||||
|
"workspaces": [
|
||||||
|
"apps/*",
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo dev",
|
||||||
|
"build": "turbo build",
|
||||||
|
"typecheck": "turbo typecheck",
|
||||||
|
"lint": "turbo lint",
|
||||||
|
"test": "turbo test",
|
||||||
|
"check": "turbo typecheck lint test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "2.10.10",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@tiptap/core": "3.22.4",
|
||||||
|
"@tiptap/extension-blockquote": "3.22.4",
|
||||||
|
"@tiptap/extension-bold": "3.22.4",
|
||||||
|
"@tiptap/extension-bubble-menu": "3.22.4",
|
||||||
|
"@tiptap/extension-bullet-list": "3.22.4",
|
||||||
|
"@tiptap/extension-code": "3.22.4",
|
||||||
|
"@tiptap/extension-code-block": "3.22.4",
|
||||||
|
"@tiptap/extension-color": "3.22.4",
|
||||||
|
"@tiptap/extension-document": "3.22.4",
|
||||||
|
"@tiptap/extension-dropcursor": "3.22.4",
|
||||||
|
"@tiptap/extension-floating-menu": "3.22.4",
|
||||||
|
"@tiptap/extension-gapcursor": "3.22.4",
|
||||||
|
"@tiptap/extension-hard-break": "3.22.4",
|
||||||
|
"@tiptap/extension-heading": "3.22.4",
|
||||||
|
"@tiptap/extension-horizontal-rule": "3.22.4",
|
||||||
|
"@tiptap/extension-italic": "3.22.4",
|
||||||
|
"@tiptap/extension-link": "3.22.4",
|
||||||
|
"@tiptap/extension-list": "3.22.4",
|
||||||
|
"@tiptap/extension-list-item": "3.22.4",
|
||||||
|
"@tiptap/extension-list-keymap": "3.22.4",
|
||||||
|
"@tiptap/extension-ordered-list": "3.22.4",
|
||||||
|
"@tiptap/extension-paragraph": "3.22.4",
|
||||||
|
"@tiptap/extension-strike": "3.22.4",
|
||||||
|
"@tiptap/extension-text": "3.22.4",
|
||||||
|
"@tiptap/extension-text-align": "3.22.4",
|
||||||
|
"@tiptap/extension-text-style": "3.22.4",
|
||||||
|
"@tiptap/extension-underline": "3.22.4",
|
||||||
|
"@tiptap/extensions": "3.22.4",
|
||||||
|
"@tiptap/pm": "3.22.4",
|
||||||
|
"@tiptap/react": "3.22.4",
|
||||||
|
"@tiptap/starter-kit": "3.22.4",
|
||||||
|
"@typescript-eslint/eslint-plugin": "8.60.1",
|
||||||
|
"@typescript-eslint/parser": "8.60.1",
|
||||||
|
"@typescript-eslint/project-service": "8.60.1",
|
||||||
|
"@typescript-eslint/scope-manager": "8.60.1",
|
||||||
|
"@typescript-eslint/tsconfig-utils": "8.60.1",
|
||||||
|
"@typescript-eslint/type-utils": "8.60.1",
|
||||||
|
"@typescript-eslint/types": "8.60.1",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||||
|
"@typescript-eslint/utils": "8.60.1",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||||
|
"typescript-eslint": "8.60.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# @beenvoice/domain
|
||||||
|
|
||||||
|
Platform-neutral Beenvoice rules shared by the web and mobile applications.
|
||||||
|
|
||||||
|
This package may use TypeScript and Web-standard APIs available in all target runtimes. It must not import Next.js, React DOM, React Native, database code, environment configuration, filesystem APIs, or native storage.
|
||||||
|
|
||||||
|
Current responsibilities:
|
||||||
|
|
||||||
|
- Expense category vocabulary
|
||||||
|
- Invoice status calculation and transitions
|
||||||
|
- Receipt-text parsing
|
||||||
|
- Time-clock display primitives
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@beenvoice/domain",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./expense-categories": "./src/expense-categories.ts",
|
||||||
|
"./invoice-status": "./src/invoice-status.ts",
|
||||||
|
"./receipt-parse": "./src/receipt-parse.ts",
|
||||||
|
"./time-clock": "./src/time-clock.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --noEmit",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "1.3.14",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const EXPENSE_CATEGORIES = [
|
||||||
|
"Travel",
|
||||||
|
"Meals & Entertainment",
|
||||||
|
"Software & Subscriptions",
|
||||||
|
"Hardware & Equipment",
|
||||||
|
"Office Supplies",
|
||||||
|
"Marketing",
|
||||||
|
"Professional Services",
|
||||||
|
"Utilities",
|
||||||
|
"Other",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ExpenseCategory = (typeof EXPENSE_CATEGORIES)[number];
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./expense-categories";
|
||||||
|
export * from "./invoice-status";
|
||||||
|
export * from "./receipt-parse";
|
||||||
|
export * from "./time-clock";
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export type StoredInvoiceStatus = "draft" | "sent" | "paid";
|
||||||
|
export type EffectiveInvoiceStatus = StoredInvoiceStatus | "overdue";
|
||||||
|
|
||||||
|
export function getEffectiveInvoiceStatus(
|
||||||
|
storedStatus: StoredInvoiceStatus,
|
||||||
|
dueDate: Date | string,
|
||||||
|
): EffectiveInvoiceStatus {
|
||||||
|
if (storedStatus === "paid" || storedStatus === "draft") return storedStatus;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const due = new Date(dueDate);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
due.setHours(0, 0, 0, 0);
|
||||||
|
return due < today ? "overdue" : "sent";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInvoiceOverdue(
|
||||||
|
storedStatus: StoredInvoiceStatus,
|
||||||
|
dueDate: Date | string,
|
||||||
|
): boolean {
|
||||||
|
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "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);
|
||||||
|
return Math.max(0, Math.ceil((today.getTime() - due.getTime()) / 86_400_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getValidStatusTransitions(
|
||||||
|
currentStatus: StoredInvoiceStatus,
|
||||||
|
): StoredInvoiceStatus[] {
|
||||||
|
switch (currentStatus) {
|
||||||
|
case "draft":
|
||||||
|
return ["sent", "paid"];
|
||||||
|
case "sent":
|
||||||
|
return ["paid", "draft"];
|
||||||
|
case "paid":
|
||||||
|
return ["sent"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidStatusTransition(
|
||||||
|
from: StoredInvoiceStatus,
|
||||||
|
to: StoredInvoiceStatus,
|
||||||
|
): boolean {
|
||||||
|
return getValidStatusTransitions(from).includes(to);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
export type ReceiptParseResult = {
|
||||||
|
amount: number | null;
|
||||||
|
date: Date | null;
|
||||||
|
subtotal: number | null;
|
||||||
|
tax: number | null;
|
||||||
|
vendor: string | null;
|
||||||
|
items: ReceiptLineItem[];
|
||||||
|
rawLines: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReceiptLineItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
rawLine: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AMOUNT_PATTERNS = [
|
||||||
|
/^\s*(?:grand total|total|amount due|balance due)[:\s]*\$?\s*([\d,]+\.\d{2})/im,
|
||||||
|
/\$\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})/,
|
||||||
|
];
|
||||||
|
const SUBTOTAL_PATTERNS = [/(?:sub\s?total|subtotal)[:\s]*\$?\s*([\d,]+\.\d{2})/i];
|
||||||
|
const TAX_PATTERNS = [/(?:tax|sales tax|hst|gst|pst|vat)[:\s]*\$?\s*([\d,]+\.\d{2})/i];
|
||||||
|
const NON_ITEM_LINE =
|
||||||
|
/(?:total|subtotal|sub total|tax|tip|gratuity|change|cash|visa|mastercard|amex|discover|card|credit|debit|balance|amount due|auth|approval|terminal|merchant|receipt|order|invoice|thank|powered by)/i;
|
||||||
|
|
||||||
|
function parseFirstMatchingAmount(text: string, patterns: RegExp[]): number | null {
|
||||||
|
for (const pattern of 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;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAmount(text: string): number | null {
|
||||||
|
const labeledAmount = parseFirstMatchingAmount(text, AMOUNT_PATTERNS);
|
||||||
|
if (labeledAmount != null && labeledAmount > 0) return labeledAmount;
|
||||||
|
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
|
||||||
|
.map((match) => Number(match[1]!.replace(/,/g, "")))
|
||||||
|
.filter((amount) => Number.isFinite(amount) && amount > 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 parseLineItems(lines: string[]): ReceiptLineItem[] {
|
||||||
|
const items: ReceiptLineItem[] = [];
|
||||||
|
for (const [index, rawLine] of lines.entries()) {
|
||||||
|
const line = rawLine.replace(/\s+/g, " ").trim();
|
||||||
|
if (line.length < 5 || NON_ITEM_LINE.test(line)) continue;
|
||||||
|
const match = line.match(/^(.{2,}?)\s+\$?(-?[\d,]+\.\d{2})$/);
|
||||||
|
if (!match?.[1] || !match[2]) continue;
|
||||||
|
const amount = Number(match[2].replace(/,/g, ""));
|
||||||
|
const name = match[1].replace(/^\d+\s*[xX]\s+/, "").replace(/\s+\d+\s*[xX]\s*$/, "").trim();
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0 || name.length < 2) continue;
|
||||||
|
items.push({
|
||||||
|
id: `${index}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${amount.toFixed(2)}`,
|
||||||
|
name: name.slice(0, 80),
|
||||||
|
amount,
|
||||||
|
rawLine,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items.slice(0, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
subtotal: parseFirstMatchingAmount(normalized, SUBTOTAL_PATTERNS),
|
||||||
|
tax: parseFirstMatchingAmount(normalized, TAX_PATTERNS),
|
||||||
|
vendor: rawLines.find((line) => line.length >= 3)?.slice(0, 120) ?? null,
|
||||||
|
items: parseLineItems(rawLines),
|
||||||
|
rawLines,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type ClockOutOutcome =
|
||||||
|
| "linked_to_invoice"
|
||||||
|
| "saved_no_invoice"
|
||||||
|
| "saved_no_client"
|
||||||
|
| "zero_hours";
|
||||||
|
|
||||||
|
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In";
|
||||||
|
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
||||||
|
|
||||||
|
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((value) => String(value).padStart(2, "0")).join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatElapsedHoursMinutes(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${h}:${String(m).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/// <reference types="bun" />
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
EXPENSE_CATEGORIES,
|
||||||
|
formatElapsedSeconds,
|
||||||
|
getEffectiveInvoiceStatus,
|
||||||
|
parseReceiptText,
|
||||||
|
} from "../src";
|
||||||
|
|
||||||
|
describe("shared domain behavior", () => {
|
||||||
|
test("exposes the expense category vocabulary", () => {
|
||||||
|
expect(EXPENSE_CATEGORIES).toContain("Professional Services");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calculates overdue status at day precision", () => {
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
expect(getEffectiveInvoiceStatus("sent", yesterday)).toBe("overdue");
|
||||||
|
expect(getEffectiveInvoiceStatus("paid", yesterday)).toBe("paid");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats elapsed time", () => {
|
||||||
|
expect(formatElapsedSeconds(3_661)).toBe("01:01:01");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts receipt totals and line items", () => {
|
||||||
|
const receipt = parseReceiptText(
|
||||||
|
"Corner Store\nCoffee 3.50\nSubtotal 3.50\nTax 0.31\nTotal 3.81",
|
||||||
|
);
|
||||||
|
expect(receipt.amount).toBe(3.81);
|
||||||
|
expect(receipt.tax).toBe(0.31);
|
||||||
|
expect(receipt.items[0]?.name).toBe("Coffee");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -11,10 +11,10 @@ set -euo pipefail
|
|||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
if [[ -f .env ]]; then
|
if [[ -f apps/web/.env ]]; then
|
||||||
set -a
|
set -a
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source .env
|
source apps/web/.env
|
||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"target": "ES2022"
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"tasks": {
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": [".next/**", "dist/**"]
|
||||||
|
},
|
||||||
|
"@beenvoice/domain#build": {
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"@beenvoice/mobile#build": {
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"typecheck": {
|
||||||
|
"dependsOn": ["^typecheck"],
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"dependsOn": ["^lint"],
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user