Convert Beenvoice to a Turborepo monorepo
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -10,7 +10,8 @@ Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) b
|
||||
## Conventions
|
||||
|
||||
- **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`
|
||||
- **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`)
|
||||
- **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` |
|
||||
| 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.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+10
-10
@@ -1,20 +1,20 @@
|
||||
# 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)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [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
|
||||
- **Not Expo Go** — widgets, SecureStore auth, and biometrics need `expo-dev-client`
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd beenvoice-app
|
||||
cd apps/mobile
|
||||
bun install
|
||||
cp .env.example .env
|
||||
```
|
||||
@@ -36,11 +36,11 @@ Server must enable `@better-auth/expo` in `beenvoice/src/lib/auth.ts` with `been
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# Terminal 1 — API
|
||||
cd ../beenvoice-web && bun run dev
|
||||
# Terminal 1, from the repository root — API + Metro
|
||||
bun run dev
|
||||
|
||||
# Terminal 2 — mobile (builds native app if needed)
|
||||
cd beenvoice-app && bun run ios
|
||||
# Terminal 2, from the repository root — native app
|
||||
bun run --filter @beenvoice/mobile ios
|
||||
```
|
||||
|
||||
Metro uses port **8082** (avoids other Expo projects on 8081).
|
||||
@@ -145,6 +145,6 @@ widgets/ # iOS Live Activity (TimeClockActivity)
|
||||
|
||||
## Related
|
||||
|
||||
- [beenvoice-web README](../beenvoice-web/README.md)
|
||||
- [beenvoice-web ARCHITECTURE](../beenvoice-web/docs/ARCHITECTURE.md)
|
||||
- [Workspace root README](../README.md)
|
||||
- [Web README](../web/README.md)
|
||||
- [Web architecture](../web/docs/ARCHITECTURE.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.
|
||||
|
||||
```bash
|
||||
cd beenvoice-app
|
||||
cd apps/mobile
|
||||
cp .ios-release.env.example .ios-release.env # once — add Team ID + API key
|
||||
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)
|
||||
|
||||
```bash
|
||||
cd beenvoice-app
|
||||
cd apps/mobile
|
||||
|
||||
# Production iOS build (auto-increments build number)
|
||||
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.
|
||||
|
||||
@@ -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) |
|
||||
| API | tRPC 11 + TanStack Query, SuperJSON |
|
||||
| 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
|
||||
|
||||
@@ -271,6 +272,6 @@ Requires beenvoice with:
|
||||
|
||||
- `@better-auth/expo` in `src/lib/auth.ts`
|
||||
- `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
|
||||
|
||||
```bash
|
||||
cd beenvoice-app
|
||||
cd apps/mobile
|
||||
cp .ios-release.env.example .ios-release.env
|
||||
```
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# beenvoice-app documentation
|
||||
# Beenvoice mobile documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
@@ -8,5 +8,5 @@
|
||||
|
||||
## Related
|
||||
|
||||
- [beenvoice-web docs](../../beenvoice-web/docs/README.md) — server API and web app
|
||||
- [Workspace README](../../README.md) — full-stack layout
|
||||
- [Web docs](../../web/docs/README.md) — server API and web app
|
||||
- [Workspace README](../../../README.md) — full-stack layout
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
export const EXPENSE_CATEGORIES = [
|
||||
"Travel",
|
||||
"Meals & Entertainment",
|
||||
"Software & Subscriptions",
|
||||
"Hardware & Equipment",
|
||||
"Office Supplies",
|
||||
"Marketing",
|
||||
"Professional Services",
|
||||
"Utilities",
|
||||
"Other",
|
||||
] as const;
|
||||
export {
|
||||
EXPENSE_CATEGORIES,
|
||||
type ExpenseCategory,
|
||||
} from "@beenvoice/domain/expense-categories";
|
||||
|
||||
@@ -1,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: {
|
||||
status: string;
|
||||
dueDate: Date | string;
|
||||
}): InvoiceStatus {
|
||||
if (invoice.status === "paid") return "paid";
|
||||
if (invoice.status === "draft") return "draft";
|
||||
|
||||
const today = new Date();
|
||||
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";
|
||||
if (invoice.status === "paid" || invoice.status === "draft") {
|
||||
return invoice.status;
|
||||
}
|
||||
return getEffectiveInvoiceStatus("sent", invoice.dueDate);
|
||||
}
|
||||
|
||||
export const statusLabels: Record<InvoiceStatus, string> = {
|
||||
|
||||
@@ -1,128 +1,5 @@
|
||||
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 = [
|
||||
/(?: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,
|
||||
};
|
||||
}
|
||||
export {
|
||||
parseReceiptText,
|
||||
type ReceiptLineItem,
|
||||
type ReceiptParseResult,
|
||||
} from "@beenvoice/domain/receipt-parse";
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
export type ClockOutOutcome =
|
||||
| "linked_to_invoice"
|
||||
| "saved_no_invoice"
|
||||
| "saved_no_client"
|
||||
| "zero_hours";
|
||||
import {
|
||||
DEFAULT_CLOCK_DESCRIPTION,
|
||||
LEGACY_DEFAULT_CLOCK_DESCRIPTION,
|
||||
} from "@beenvoice/domain/time-clock";
|
||||
import type { ClockOutOutcome } from "@beenvoice/domain/time-clock";
|
||||
|
||||
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In";
|
||||
|
||||
/** Stored on entries clocked in before empty descriptions were allowed. */
|
||||
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
||||
export {
|
||||
DEFAULT_CLOCK_DESCRIPTION,
|
||||
formatElapsedHoursMinutes,
|
||||
formatElapsedSeconds,
|
||||
LEGACY_DEFAULT_CLOCK_DESCRIPTION,
|
||||
type ClockOutOutcome,
|
||||
} from "@beenvoice/domain/time-clock";
|
||||
|
||||
export function resolveClockDescription(description: string | null | undefined): string {
|
||||
const trimmed = description?.trim();
|
||||
@@ -26,20 +29,6 @@ export function formatRunningTimerLabel(description?: string | null): string {
|
||||
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(
|
||||
rateText: string,
|
||||
clientDefaultRate?: number | null,
|
||||
|
||||
+52
-42
@@ -1,67 +1,77 @@
|
||||
{
|
||||
"name": "beenvoice-app",
|
||||
"name": "@beenvoice/mobile",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@better-auth/expo": "^1.6.19",
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo-google-fonts/playfair-display": "^0.4.2",
|
||||
"@expo/ui": "~57.0.11",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@beenvoice/domain": "workspace:*",
|
||||
"@better-auth/expo": "1.6.19",
|
||||
"@expo-google-fonts/inter": "0.4.2",
|
||||
"@expo-google-fonts/playfair-display": "0.4.2",
|
||||
"@expo/ui": "57.0.11",
|
||||
"@expo/vector-icons": "15.1.1",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "9.1.0",
|
||||
"@react-native-picker/picker": "^2.11.4",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@trpc/client": "^11.17.0",
|
||||
"@trpc/react-query": "^11.17.0",
|
||||
"better-auth": "^1.6.19",
|
||||
"expo": "^57.0.9",
|
||||
"expo-blur": "~57.0.2",
|
||||
"expo-build-properties": "~57.0.11",
|
||||
"expo-constants": "~57.0.11",
|
||||
"expo-dev-client": "~57.0.12",
|
||||
"expo-file-system": "~57.0.4",
|
||||
"expo-font": "~57.0.1",
|
||||
"expo-image": "~57.0.3",
|
||||
"expo-image-picker": "~57.0.10",
|
||||
"expo-linear-gradient": "~57.0.1",
|
||||
"expo-linking": "~57.0.6",
|
||||
"expo-local-authentication": "~57.0.2",
|
||||
"expo-mlkit-ocr": "^0.2.7",
|
||||
"expo-network": "~57.0.1",
|
||||
"expo-notifications": "~57.0.11",
|
||||
"expo-router": "~57.0.13",
|
||||
"expo-secure-store": "~57.0.1",
|
||||
"expo-sharing": "~57.0.12",
|
||||
"expo-splash-screen": "~57.0.6",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"expo-symbols": "~57.0.2",
|
||||
"expo-web-browser": "~57.0.2",
|
||||
"expo-widgets": "~57.0.10",
|
||||
"@react-native-picker/picker": "2.11.4",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@trpc/client": "11.17.0",
|
||||
"@trpc/react-query": "11.17.0",
|
||||
"@trpc/server": "11.17.0",
|
||||
"better-auth": "1.6.19",
|
||||
"expo": "57.0.13",
|
||||
"expo-blur": "57.0.2",
|
||||
"expo-build-properties": "57.0.11",
|
||||
"expo-constants": "57.0.11",
|
||||
"expo-dev-client": "57.0.12",
|
||||
"expo-file-system": "57.0.4",
|
||||
"expo-font": "57.0.1",
|
||||
"expo-image": "57.0.3",
|
||||
"expo-image-picker": "57.0.10",
|
||||
"expo-linear-gradient": "57.0.1",
|
||||
"expo-linking": "57.0.6",
|
||||
"expo-local-authentication": "57.0.2",
|
||||
"expo-mlkit-ocr": "0.2.7",
|
||||
"expo-modules-core": "57.0.11",
|
||||
"expo-network": "57.0.1",
|
||||
"expo-notifications": "57.0.11",
|
||||
"expo-router": "57.0.13",
|
||||
"expo-secure-store": "57.0.1",
|
||||
"expo-sharing": "57.0.12",
|
||||
"expo-splash-screen": "57.0.6",
|
||||
"expo-status-bar": "57.0.1",
|
||||
"expo-symbols": "57.0.2",
|
||||
"expo-web-browser": "57.0.2",
|
||||
"expo-widgets": "57.0.10",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.2",
|
||||
"react-native-gesture-handler": "3.2.1",
|
||||
"react-native-reanimated": "4.5.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0",
|
||||
"react-native-safe-area-context": "5.7.0",
|
||||
"react-native-screens": "4.26.2",
|
||||
"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-worklets": "0.10.1",
|
||||
"superjson": "^2.2.6"
|
||||
"superjson": "2.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/react": "~19.2.2",
|
||||
"react-native-svg-transformer": "^1.5.3",
|
||||
"typescript": "~6.0.3"
|
||||
"@expo/config-plugins": "57.0.8",
|
||||
"@types/bun": "1.3.14",
|
||||
"@types/react": "19.2.17",
|
||||
"react-native-svg-transformer": "1.5.3",
|
||||
"typescript": "6.0.3",
|
||||
"xcode": "3.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "expo start --dev-client --port 8082",
|
||||
"start": "expo start --dev-client --port 8082",
|
||||
"android": "expo run:android --port 8082",
|
||||
"ios": "expo run:ios --port 8082",
|
||||
"ios:release": "bash scripts/ios-release.sh",
|
||||
"ios:release:upload": "bash scripts/ios-release.sh --upload",
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "bun test",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
/// <reference types="bun" />
|
||||
|
||||
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 { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories";
|
||||
import { getInvoiceStatus } from "../lib/invoice-status";
|
||||
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
|
||||
import { EXPENSE_CATEGORIES as webExpenseCategories } from "../../beenvoice-web/src/lib/expense-categories";
|
||||
import { getEffectiveInvoiceStatus } from "../../beenvoice-web/src/lib/invoice-status";
|
||||
import { safeCallbackPath } from "../../beenvoice-web/src/lib/safe-callback-url";
|
||||
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
|
||||
import {
|
||||
formatElapsedSeconds as formatWebElapsedSeconds,
|
||||
normalizeOptionalId,
|
||||
} from "../../beenvoice-web/src/lib/time-clock";
|
||||
} from "../../web/src/lib/time-clock";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -85,7 +87,7 @@ describe("timer parity", () => {
|
||||
test("elapsed time formatting is identical", () => {
|
||||
for (const seconds of [0, 59, 60, 3_661, 86_399]) {
|
||||
expect(formatAppElapsedSeconds(seconds)).toBe(
|
||||
formatWebElapsedSeconds(seconds),
|
||||
formatDomainElapsedSeconds(seconds),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -99,6 +101,6 @@ describe("timer parity", () => {
|
||||
|
||||
describe("expense parity", () => {
|
||||
test("web and mobile expose the same category vocabulary", () => {
|
||||
expect([...appExpenseCategories]).toEqual([...webExpenseCategories]);
|
||||
expect([...appExpenseCategories]).toEqual([...domainExpenseCategories]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"~/*": ["../beenvoice-web/src/*"],
|
||||
"src/*": ["../beenvoice-web/src/*"],
|
||||
"beenvoice/*": ["../beenvoice-web/src/*"]
|
||||
"~/*": ["../web/src/*"],
|
||||
"src/*": ["../web/src/*"],
|
||||
"beenvoice/*": ["../web/src/*"]
|
||||
}
|
||||
},
|
||||
"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):
|
||||
|
||||
+2
-4
@@ -1,11 +1,9 @@
|
||||
# beenvoice - AI Assistant Rules
|
||||
|
||||
> **Canonical architecture reference:** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (stack, routers, schema, auth). This file may lag behind; prefer ARCHITECTURE.md for facts.
|
||||
> **Canonical architecture reference:** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (stack, routers, schema, auth). Root workspace rules are in [../../AGENTS.md](../../AGENTS.md).
|
||||
|
||||
## Project Overview
|
||||
beenvoice-web is the web app and API for beenvoice — Next.js 16, tRPC 11, Drizzle/PostgreSQL, better-auth, and shadcn/ui. Reliability, security, and professional UX are paramount.
|
||||
|
||||
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
|
||||
`apps/web` is the web app and API for Beenvoice — Next.js 16, tRPC 11, Drizzle/PostgreSQL, Better Auth, and shadcn/ui. Reliability, security, and professional UX are paramount.
|
||||
|
||||
## Core Development Principles
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM oven/bun:1 AS base
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
FROM base AS install
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Next.js build must run on Node — Bun 1.3.x can SIGSEGV on Linux arm64 during
|
||||
# the "Collecting page data" worker phase (oven-sh/bun#...). Runtime stays on Bun.
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /usr/src/app
|
||||
COPY --from=install /usr/src/app/node_modules node_modules
|
||||
COPY . .
|
||||
|
||||
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
ARG BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# Low-memory Docker build profile:
|
||||
# - skip tsc inside `next build` (run `bun run check` in CI instead)
|
||||
ENV DOCKER_BUILD=1 \
|
||||
NODE_ENV=production \
|
||||
SKIP_ENV_VALIDATION=1 \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
BETTER_AUTH_URL=${BETTER_AUTH_URL} \
|
||||
NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} \
|
||||
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
||||
RUN node ./node_modules/next/dist/bin/next build
|
||||
|
||||
FROM base AS release
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY --from=build /usr/src/app/.next ./.next
|
||||
COPY --from=build /usr/src/app/public ./public
|
||||
COPY --from=install /usr/src/app/node_modules node_modules
|
||||
COPY --from=build /usr/src/app/package.json ./package.json
|
||||
COPY --from=build /usr/src/app/drizzle.config.ts ./drizzle.config.ts
|
||||
COPY --from=build /usr/src/app/drizzle ./drizzle
|
||||
COPY --from=build /usr/src/app/src/server/db/migrate.ts ./migrate.ts
|
||||
|
||||
RUN chmod -R a+rX drizzle public migrate.ts
|
||||
|
||||
USER bun
|
||||
EXPOSE 3000
|
||||
CMD ["sh", "-c", "bun migrate.ts && bun run start"]
|
||||
+19
-23
@@ -1,12 +1,11 @@
|
||||

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