Fix Docker signup, refresh docs, and improve blank invoice flow.
Parse DISABLE_SIGNUPS and related env booleans correctly for Compose string values, and derive auth trustedOrigins from BETTER_AUTH_URL. Rewrite README and architecture docs with the git.soconnor.dev remote and accurate deployment guidance. Allow zero-line-item draft invoices with validation when sending email. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+6
-2
@@ -1,5 +1,6 @@
|
|||||||
# Copy this file to .env before running Docker Compose:
|
# beenvoice-web environment
|
||||||
# cp .env.example .env
|
# Local dev: cp .env.example .env.local
|
||||||
|
# Docker: cp .env.example .env
|
||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
@@ -43,6 +44,9 @@ RESEND_DOMAIN=
|
|||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
|
||||||
|
|
||||||
|
# Block new email/password registrations (optional)
|
||||||
|
# DISABLE_SIGNUPS=true
|
||||||
|
|
||||||
# SSO via Authentik OIDC (optional)
|
# SSO via Authentik OIDC (optional)
|
||||||
NEXT_PUBLIC_AUTHENTIK_ENABLED=false
|
NEXT_PUBLIC_AUTHENTIK_ENABLED=false
|
||||||
AUTHENTIK_ISSUER=
|
AUTHENTIK_ISSUER=
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
> **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). This file may lag behind; prefer ARCHITECTURE.md for facts.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
beenvoice is a professional invoicing application built with the T3 stack (Next.js 15, tRPC, Drizzle/LibSQL, NextAuth.js) and shadcn/ui components. This is a business-critical application where reliability, security, and professional user experience are paramount.
|
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)
|
||||||
|
|
||||||
## Core Development Principles
|
## Core Development Principles
|
||||||
|
|
||||||
@@ -21,10 +23,9 @@ beenvoice is a professional invoicing application built with the T3 stack (Next.
|
|||||||
|
|
||||||
## Tech Stack Guidelines
|
## Tech Stack Guidelines
|
||||||
|
|
||||||
### Frontend (Next.js 15 + App Router)
|
### Frontend (Next.js 16 + App Router)
|
||||||
- Use App Router patterns consistently
|
- Use App Router patterns consistently
|
||||||
- Implement proper loading states and error boundaries
|
- Implement proper loading states and error boundaries
|
||||||
- Follow Next.js 15 best practices for performance
|
|
||||||
- Use React Server Components where appropriate
|
- Use React Server Components where appropriate
|
||||||
|
|
||||||
### Backend (tRPC + Drizzle)
|
### Backend (tRPC + Drizzle)
|
||||||
@@ -33,17 +34,16 @@ beenvoice is a professional invoicing application built with the T3 stack (Next.
|
|||||||
- Implement proper transactions for multi-table operations
|
- Implement proper transactions for multi-table operations
|
||||||
- Follow existing router patterns in `src/server/api/routers/`
|
- Follow existing router patterns in `src/server/api/routers/`
|
||||||
|
|
||||||
### Database (LibSQL/SQLite)
|
### Database (PostgreSQL)
|
||||||
- Use Drizzle migrations for schema changes
|
- Use Drizzle migrations in `drizzle/`; journal must stay in sync (`drizzle/meta/_journal.json`)
|
||||||
- Implement proper indexes for performance
|
- `db:push` for local iteration; Docker runs `migrate.ts` on startup
|
||||||
- Follow existing schema patterns
|
- Follow existing schema patterns in `src/server/db/schema.ts`
|
||||||
- Use transactions for data consistency
|
|
||||||
|
|
||||||
### Authentication (NextAuth.js)
|
### Authentication (better-auth)
|
||||||
- Email/password authentication with bcrypt hashing
|
- Email/password via better-auth + custom `/api/auth/register` REST
|
||||||
- Proper session management
|
- Optional Authentik OIDC (`AUTHENTIK_*`); Expo plugin for mobile
|
||||||
- Protected routes require authentication
|
- Route protection via `src/proxy.ts` (session cookie check) and `protectedProcedure`
|
||||||
- Follow NextAuth.js security best practices
|
- `DISABLE_SIGNUPS=true` blocks registration; env booleans parsed in `src/env.js` (not `z.coerce.boolean`)
|
||||||
|
|
||||||
### Development Tools
|
### Development Tools
|
||||||
- Use ESLint and Prettier for code formatting
|
- Use ESLint and Prettier for code formatting
|
||||||
@@ -409,10 +409,9 @@ Migrations run automatically at container startup via `bun migrate.ts` (see Dock
|
|||||||
## Deployment & Production
|
## Deployment & Production
|
||||||
|
|
||||||
### Environment Configuration
|
### Environment Configuration
|
||||||
- Use proper environment variables
|
- Use proper environment variables (see `.env.example` and `src/env.js`)
|
||||||
- Secure database connections
|
- `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` must match the public hostname
|
||||||
- Configure NextAuth.js properly
|
- Secure database connections; `DB_DISABLE_SSL=true` for compose Postgres
|
||||||
- Set up proper logging
|
|
||||||
|
|
||||||
### Database Management
|
### Database Management
|
||||||
- Use migrations for schema changes
|
- Use migrations for schema changes
|
||||||
|
|||||||
@@ -1,366 +1,219 @@
|
|||||||

|

|
||||||
|
|
||||||
# beenvoice — Invoicing Made Simple
|
# beenvoice-web
|
||||||
|
|
||||||
Modern invoicing for freelancers and small businesses: clients, businesses, invoices, time tracking, expenses, recurring billing, PDF/email delivery, and optional SSO.
|
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.
|
||||||
|
|
||||||
**Architecture (dense):** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
|
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
|
||||||
**Mobile companion:** [../beenvoice-app/README.md](../beenvoice-app/README.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)
|
||||||
|
|
||||||
## Stack at a glance
|
## Stack
|
||||||
|
|
||||||
| Layer | Tech |
|
| Layer | Technology |
|
||||||
|-------|------|
|
|-------|------------|
|
||||||
| App | Next.js 16 App Router, React 19 |
|
| App | Next.js 16 App Router, React 19 |
|
||||||
| API | tRPC 11 + SuperJSON |
|
| API | tRPC 11 + SuperJSON |
|
||||||
| DB | PostgreSQL, Drizzle ORM |
|
| Database | PostgreSQL 17, Drizzle ORM |
|
||||||
| Auth | better-auth (email/password, Authentik OIDC, Expo mobile) |
|
| Auth | better-auth (email/password, optional Authentik OIDC, Expo mobile) |
|
||||||
| UI | shadcn/ui, Tailwind v4 |
|
| UI | shadcn/ui, Tailwind CSS v4 |
|
||||||
| Email / PDF | Resend, @react-pdf/renderer |
|
| Email / PDF | Resend, `@react-pdf/renderer` |
|
||||||
| Package manager | Bun |
|
| Runtime | Bun |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **🔐 Authentication** — better-auth: email/password, password reset, optional Authentik OIDC, Expo mobile sessions
|
- Clients, businesses, invoices (line items, tax, status workflow)
|
||||||
- **⏱ Time clock** — running timer, one per user; clock-out can append invoice line items
|
- Time clock with one running timer per user; clock-out can append invoice lines
|
||||||
- **🤖 MCP API** — `/api/mcp` for automation via API keys (`bv_…`)
|
- Expenses, payments, recurring invoices, invoice templates
|
||||||
- **👥 Client Management** - Create, edit, and manage client information
|
- PDF export and email delivery (Resend)
|
||||||
- **🏢 Business Profiles** - Manage your business details, logo, and email settings
|
- Public invoice links (`/i/[token]`)
|
||||||
- **📄 Professional Invoices** - Generate detailed invoices with line items
|
- CSV import, reports, platform branding / admin settings
|
||||||
- **📅 Timesheet View** - Calendar-based time entry with month and week views
|
- MCP API (`/api/mcp`) for automation via API keys (`bv_…`)
|
||||||
- **📧 Email Delivery** - Send invoices via email using Resend
|
- Optional Authentik OIDC SSO
|
||||||
- **📥 PDF Export** - Download invoices as professional PDFs
|
|
||||||
- **📊 CSV Import** - Bulk import invoice data from CSV files
|
|
||||||
- **💰 Flexible Pricing** - Set custom rates and calculate totals automatically
|
|
||||||
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
|
|
||||||
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
|
|
||||||
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
|
|
||||||
- **💾 PostgreSQL Database** - Robust relational database with Drizzle ORM
|
|
||||||
|
|
||||||
## 🚀 Tech Stack
|
## Prerequisites
|
||||||
|
|
||||||
- **Frontend**: Next.js 16 with App Router
|
- [Bun](https://bun.sh) 1.x
|
||||||
- **Backend**: tRPC for type-safe API calls
|
- Docker & Docker Compose (for PostgreSQL locally or full-stack deploy)
|
||||||
- **Database**: Drizzle ORM with PostgreSQL
|
|
||||||
- **Authentication**: better-auth with email/password and Authentik OIDC SSO
|
|
||||||
- **UI Components**: shadcn/ui with Tailwind CSS v4
|
|
||||||
- **Email**: Resend for transactional email delivery
|
|
||||||
- **PDF**: @react-pdf/renderer for invoice PDF generation
|
|
||||||
- **Package Manager**: Bun
|
|
||||||
|
|
||||||
## 📦 Installation
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 18+ or Bun
|
|
||||||
- Docker & Docker Compose (for local PostgreSQL)
|
|
||||||
- Git
|
- Git
|
||||||
|
|
||||||
### Quick Start
|
## Local development
|
||||||
|
|
||||||
1. **Clone the repository**
|
### 1. Clone and install
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/yourusername/beenvoice.git
|
|
||||||
cd beenvoice
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install dependencies**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Set up environment variables**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit `.env.local` and add your configuration:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Database
|
|
||||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
|
|
||||||
DB_DISABLE_SSL="true"
|
|
||||||
|
|
||||||
# Authentication
|
|
||||||
AUTH_SECRET="your-secret-key-here"
|
|
||||||
BETTER_AUTH_URL="http://localhost:3000"
|
|
||||||
|
|
||||||
# Application
|
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|
||||||
NODE_ENV="development"
|
|
||||||
|
|
||||||
# Email (optional for local dev)
|
|
||||||
RESEND_API_KEY="your-resend-api-key"
|
|
||||||
RESEND_DOMAIN="yourdomain.com"
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Start the development database**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml up -d db
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Push the database schema**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Start the development server**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Open your browser**
|
|
||||||
Navigate to [http://localhost:3000](http://localhost:3000)
|
|
||||||
|
|
||||||
## 🏗️ Project structure
|
|
||||||
|
|
||||||
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for routers, schema, auth, and MCP.
|
|
||||||
|
|
||||||
```
|
|
||||||
beenvoice/
|
|
||||||
├── src/app/ # Pages + /api (auth, trpc, mcp, cron, public PDF)
|
|
||||||
├── src/server/api/ # tRPC routers
|
|
||||||
├── src/server/db/ # Drizzle schema + pool
|
|
||||||
├── src/components/ # UI + domain components
|
|
||||||
├── src/lib/ # auth, PDF, email, branding
|
|
||||||
├── drizzle/ # SQL migrations
|
|
||||||
└── docs/ # Architecture + UI guides
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Usage
|
|
||||||
|
|
||||||
### Getting Started
|
|
||||||
|
|
||||||
1. **Register an Account**
|
|
||||||
- Visit the sign-up page
|
|
||||||
- Enter your name, email, and password
|
|
||||||
|
|
||||||
2. **Set Up Your Business**
|
|
||||||
- Navigate to Business Settings
|
|
||||||
- Add your business name, contact info, and logo
|
|
||||||
- Configure email settings for invoice delivery (Resend API key + domain)
|
|
||||||
|
|
||||||
3. **Add Your First Client**
|
|
||||||
- Navigate to the Clients page
|
|
||||||
- Click "Add New Client"
|
|
||||||
- Fill in client details (name, email, phone, address)
|
|
||||||
|
|
||||||
4. **Create an Invoice**
|
|
||||||
- Go to the Invoices page
|
|
||||||
- Click "Create New Invoice"
|
|
||||||
- Select a client and optionally a business profile
|
|
||||||
- Add line items with descriptions, dates, hours, and rates
|
|
||||||
- Use the Timesheet tab for calendar-based time entry
|
|
||||||
- Save and send or download as PDF
|
|
||||||
|
|
||||||
### Features Overview
|
|
||||||
|
|
||||||
#### Client Management
|
|
||||||
|
|
||||||
- Create and edit client profiles
|
|
||||||
- Store contact information and addresses
|
|
||||||
- Set default hourly rates per client
|
|
||||||
- Search and filter client list
|
|
||||||
|
|
||||||
#### Invoice Creation
|
|
||||||
|
|
||||||
- Select from existing clients and business profiles
|
|
||||||
- Add multiple line items with drag-and-drop reordering
|
|
||||||
- Set custom rates per item
|
|
||||||
- Automatic total calculations with configurable tax rate
|
|
||||||
- Timesheet calendar view for date-based time tracking
|
|
||||||
- Professional invoice formatting
|
|
||||||
|
|
||||||
#### Invoice Delivery
|
|
||||||
|
|
||||||
- Send invoices via email directly from the app
|
|
||||||
- Rich text email composer with preview
|
|
||||||
- Resend and re-deliver sent invoices
|
|
||||||
- Track invoice status: Draft → Sent → Paid (+ Overdue)
|
|
||||||
|
|
||||||
#### User Interface
|
|
||||||
|
|
||||||
- Clean, modern design
|
|
||||||
- Fully responsive — desktop, tablet, and mobile
|
|
||||||
- Intuitive navigation with breadcrumbs
|
|
||||||
- Toast notifications for feedback
|
|
||||||
- Dark mode support
|
|
||||||
|
|
||||||
## 🔧 Development
|
|
||||||
|
|
||||||
### Available Scripts
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
git clone https://git.soconnor.dev/soconnor/beenvoice-web.git
|
||||||
bun run dev # Start development server (Turbo)
|
cd beenvoice-web
|
||||||
bun run build # Build for production
|
bun install
|
||||||
bun run start # Start production server
|
|
||||||
|
|
||||||
# Database
|
|
||||||
bun run db:push # Push schema changes to database
|
|
||||||
bun run db:migrate # Run migrations
|
|
||||||
bun run db:studio # Open Drizzle Studio
|
|
||||||
bun run db:generate # Generate new migration
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
bun run docker:up # Start deployment compose stack
|
|
||||||
bun run docker:dev:up # Start development compose stack with exposed PostgreSQL
|
|
||||||
bun run docker:down # Stop Docker services
|
|
||||||
|
|
||||||
# Code Quality
|
|
||||||
bun run lint # Run ESLint
|
|
||||||
bun run lint:fix # Fix ESLint issues
|
|
||||||
bun run format:write # Format code with Prettier
|
|
||||||
bun run typecheck # Run TypeScript type checking
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Compose
|
### 2. Environment
|
||||||
|
|
||||||
Use the base compose file for deployment. It keeps PostgreSQL internal to the
|
|
||||||
compose network:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
cp .env.example .env.local
|
||||||
```
|
```
|
||||||
|
|
||||||
For local development, use the dev compose file to expose PostgreSQL on
|
Edit `.env.local` for local dev. Minimum:
|
||||||
`${POSTGRES_PORT:-5432}`:
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
||||||
|
DB_DISABLE_SSL=true
|
||||||
|
AUTH_SECRET=your-dev-secret # openssl rand -base64 32
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Email and SSO are optional for local work — leave `RESEND_*` and `AUTHENTIK_*` blank unless you need them.
|
||||||
|
|
||||||
|
### 3. Database
|
||||||
|
|
||||||
|
Start Postgres (dev compose exposes port 5432):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `DISABLE_SIGNUPS=true` to block new email/password account registration.
|
Apply schema (pick one):
|
||||||
|
|
||||||
### Database Schema
|
```bash
|
||||||
|
bun run db:push # fast iteration during development
|
||||||
The application uses the following core tables:
|
# bun run db:migrate # same migrations the Docker image runs in production
|
||||||
|
|
||||||
- **users** - User accounts and authentication
|
|
||||||
- **sessions** - Active user sessions
|
|
||||||
- **clients** - Client information and contact details
|
|
||||||
- **businesses** - Business profiles with email/logo settings
|
|
||||||
- **invoices** - Invoice headers with client and business relationships
|
|
||||||
- **invoice_items** - Individual line items with pricing and position ordering
|
|
||||||
|
|
||||||
### API surface
|
|
||||||
|
|
||||||
- **tRPC** — `/api/trpc` — primary API for web and mobile (session cookies)
|
|
||||||
- **MCP** — `/api/mcp` — JSON-RPC tools for integrations (API key only)
|
|
||||||
- **REST auth** — `/api/auth/register`, forgot/reset password (mobile + custom flows)
|
|
||||||
- **Public** — `/i/[token]`, `/api/i/[token]/pdf`
|
|
||||||
|
|
||||||
All business logic lives in `src/server/api/routers/`. Input validation via Zod.
|
|
||||||
|
|
||||||
## 🎨 Customization
|
|
||||||
|
|
||||||
### Styling
|
|
||||||
|
|
||||||
The app uses Tailwind CSS v4 with a custom design system:
|
|
||||||
|
|
||||||
- **Primary Color**: Green (#16a34a)
|
|
||||||
- **Font**: Geist for professional typography
|
|
||||||
- **Components**: shadcn/ui component library
|
|
||||||
- **Spacing**: 4px grid system
|
|
||||||
|
|
||||||
### Branding
|
|
||||||
|
|
||||||
Update the logo and colors in:
|
|
||||||
|
|
||||||
- `src/components/logo.tsx` - Main logo component
|
|
||||||
- `src/styles/globals.css` - Color variables
|
|
||||||
- `src/app/layout.tsx` - Font configuration
|
|
||||||
|
|
||||||
## 🚀 Deployment
|
|
||||||
|
|
||||||
You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.).
|
|
||||||
|
|
||||||
1. **Build the application:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
|
|
||||||
|
|
||||||
3. **Run database migrations:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Start the server:**
|
|
||||||
```bash
|
|
||||||
bun start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Required for production:
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL="postgresql://user:password@host:5432/dbname"
|
|
||||||
AUTH_SECRET="your-long-random-secret"
|
|
||||||
BETTER_AUTH_URL="https://your-domain.com"
|
|
||||||
NEXT_PUBLIC_APP_URL="https://your-domain.com"
|
|
||||||
NODE_ENV="production"
|
|
||||||
|
|
||||||
# Email (required for invoice sending)
|
|
||||||
RESEND_API_KEY="re_xxxxxxxxxxxx"
|
|
||||||
RESEND_DOMAIN="yourdomain.com"
|
|
||||||
|
|
||||||
# Optional: Authentik SSO
|
|
||||||
AUTHENTIK_ISSUER="https://your-authentik-instance/application/o/beenvoice/"
|
|
||||||
AUTHENTIK_CLIENT_ID="your-client-id"
|
|
||||||
AUTHENTIK_CLIENT_SECRET="your-client-secret"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Other Platforms
|
### 4. Run
|
||||||
|
|
||||||
The app can be deployed to any platform that supports Next.js:
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
- **Coolify**: Deploy with Docker Compose support
|
Open [http://localhost:3000](http://localhost:3000), register at `/auth/register`, then sign in.
|
||||||
- **Railway**: Connect your GitHub repository (includes managed PostgreSQL)
|
|
||||||
- **DigitalOcean App Platform**: Deploy with automatic scaling
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
## Docker deployment (app + database)
|
||||||
|
|
||||||
1. Fork the repository
|
The production compose file runs the Next.js app and PostgreSQL. Migrations run automatically on container start (`bun migrate.ts` in the image `CMD`).
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
||||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
||||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
||||||
5. Open a Pull Request
|
|
||||||
|
|
||||||
### Development Guidelines
|
### 1. Configure
|
||||||
|
|
||||||
- Follow TypeScript best practices
|
```bash
|
||||||
- Use shadcn/ui components for consistency
|
cp .env.example .env
|
||||||
- Implement proper error handling
|
```
|
||||||
- Follow the existing code style (Prettier + ESLint configs provided)
|
|
||||||
|
|
||||||
## 📄 License
|
Set at least:
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
```env
|
||||||
|
AUTH_SECRET=<openssl rand -base64 32>
|
||||||
|
BETTER_AUTH_URL=https://your-public-hostname
|
||||||
|
NEXT_PUBLIC_APP_URL=https://your-public-hostname
|
||||||
|
```
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
`BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` must match the URL users actually use in the browser. If they point at `localhost` but you access the app via another hostname, auth (sign-in / sign-up) will fail.
|
||||||
|
|
||||||
- [T3 Stack](https://create.t3.gg/) for the excellent development stack
|
`NEXT_PUBLIC_*` values are embedded at **image build** time. Rebuild after changing white-label or Authentik client flags:
|
||||||
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
|
|
||||||
- [better-auth](https://www.better-auth.com/) for modern authentication
|
|
||||||
- [Drizzle ORM](https://orm.drizzle.team/) for database management
|
|
||||||
- [Resend](https://resend.com/) for reliable email delivery
|
|
||||||
|
|
||||||
## 📞 Support
|
```bash
|
||||||
|
docker compose build --no-cache app
|
||||||
|
```
|
||||||
|
|
||||||
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
|
### 2. Start
|
||||||
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
|
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
Built for freelancers and small businesses who deserve better invoicing tools.
|
App listens on `${WEB_PORT:-3000}`. Postgres stays on the internal compose network.
|
||||||
|
|
||||||
|
### 3. Sign-ups
|
||||||
|
|
||||||
|
Registration is **enabled** by default. To block new email/password accounts:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DISABLE_SIGNUPS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the literal strings `true` or `false` (or omit the variable). Do not rely on bare boolean coercion from shell/compose — the app parses these explicitly.
|
||||||
|
|
||||||
|
### 4. Optional services
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `RESEND_API_KEY`, `RESEND_DOMAIN` | Invoice and password-reset email |
|
||||||
|
| `AUTHENTIK_ISSUER`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET` | OIDC SSO (also set `NEXT_PUBLIC_AUTHENTIK_ENABLED=true` and rebuild) |
|
||||||
|
| `CRON_SECRET` | Protects `/api/cron/generate-recurring` |
|
||||||
|
| `DISABLE_SIGNUPS=true` | Block new registrations |
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
beenvoice-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 (deploy)
|
||||||
|
├── docker-compose.dev.yml # Postgres only (local dev)
|
||||||
|
└── docs/ # Architecture and UI guides
|
||||||
|
```
|
||||||
|
|
||||||
|
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for routers, schema, auth flows, and MCP.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# App
|
||||||
|
bun run dev # next dev --turbo
|
||||||
|
bun run build # production build
|
||||||
|
bun run start # next start
|
||||||
|
bun run check # eslint + tsc
|
||||||
|
|
||||||
|
# Database
|
||||||
|
bun run db:push # push schema (local dev)
|
||||||
|
bun run db:migrate # run drizzle migrations
|
||||||
|
bun run db:generate # generate new migration SQL
|
||||||
|
bun run db:studio # Drizzle Studio
|
||||||
|
|
||||||
|
# Formatting
|
||||||
|
bun run lint
|
||||||
|
bun run lint:fix
|
||||||
|
bun run format:write
|
||||||
|
bun run typecheck
|
||||||
|
|
||||||
|
# Docker helpers (Postgres only — uses Colima on macOS)
|
||||||
|
bun run docker:up # colima start + docker-compose.dev.yml up -d
|
||||||
|
bun run docker:down # stop dev Postgres + colima
|
||||||
|
```
|
||||||
|
|
||||||
|
Full-stack deploy uses `docker compose up` (see [Docker deployment](#docker-deployment-app--database)), not `bun run docker:up`.
|
||||||
|
|
||||||
|
## API surface
|
||||||
|
|
||||||
|
| Endpoint | Auth | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `/api/trpc` | Session cookie or API key | Primary API (web + mobile) |
|
||||||
|
| `/api/auth/*` | Varies | better-auth + custom register/reset REST |
|
||||||
|
| `/api/mcp` | API key only | JSON-RPC automation tools |
|
||||||
|
| `/i/[token]` | Public token | Client invoice view |
|
||||||
|
| `/api/i/[token]/pdf` | Public token | Invoice PDF download |
|
||||||
|
|
||||||
|
Business logic lives in `src/server/api/routers/` with Zod validation.
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
- **Runtime branding:** Dashboard → Administration (platform settings)
|
||||||
|
- **Build-time defaults:** `NEXT_PUBLIC_BRAND_*` in `.env` (rebuild Docker image to apply)
|
||||||
|
- **Theme / fonts:** `src/styles/globals.css`, appearance settings in the app
|
||||||
|
- **Logo component:** `src/components/branding/logo.tsx`
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Doc | Contents |
|
||||||
|
|-----|----------|
|
||||||
|
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Stack, routers, schema, auth, Docker, MCP |
|
||||||
|
| [docs/README.md](./docs/README.md) | Index of UI and product guides |
|
||||||
|
| [AGENTS.md](./AGENTS.md) | Conventions for AI-assisted development |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|||||||
+12
-8
@@ -1,6 +1,8 @@
|
|||||||
# beenvoice server architecture
|
# beenvoice-web architecture
|
||||||
|
|
||||||
Dense reference for the Next.js web application and API in `beenvoice/`. 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)
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -128,8 +130,8 @@ Migrations: `bun run db:generate` → `drizzle/`; apply with `db:push` (dev) or
|
|||||||
|
|
||||||
- `betterAuth` + `drizzleAdapter` (users, sessions, accounts, verification)
|
- `betterAuth` + `drizzleAdapter` (users, sessions, accounts, verification)
|
||||||
- Plugins: `@better-auth/expo` (mobile SecureStore cookies), `nextCookies()`, optional `genericOAuth` (Authentik)
|
- Plugins: `@better-auth/expo` (mobile SecureStore cookies), `nextCookies()`, optional `genericOAuth` (Authentik)
|
||||||
- Email/password with bcrypt (12 rounds); `DISABLE_SIGNUPS=true` blocks registration
|
- Email/password with bcrypt (12 rounds); `DISABLE_SIGNUPS=true` blocks registration (custom `/api/auth/register` and better-auth `disableSignUp`)
|
||||||
- `trustedOrigins`: production URL, `beenvoice://`, `exp://` (Expo)
|
- `trustedOrigins`: `BETTER_AUTH_URL`, `NEXT_PUBLIC_APP_URL`, `beenvoice://`, `exp://`, plus Authentik origin when configured
|
||||||
|
|
||||||
**Web client** — `src/lib/auth-client.ts`: `createAuthClient` + `genericOAuthClient`.
|
**Web client** — `src/lib/auth-client.ts`: `createAuthClient` + `genericOAuthClient`.
|
||||||
|
|
||||||
@@ -173,7 +175,7 @@ Validated in `src/env.js`. See `.env.example`.
|
|||||||
| `DB_DISABLE_SSL` | local | `true` for Docker dev DB |
|
| `DB_DISABLE_SSL` | local | `true` for Docker dev DB |
|
||||||
| `RESEND_API_KEY`, `RESEND_DOMAIN` | optional | Email; blank disables send |
|
| `RESEND_API_KEY`, `RESEND_DOMAIN` | optional | Email; blank disables send |
|
||||||
| `AUTHENTIK_*` | optional | OIDC SSO |
|
| `AUTHENTIK_*` | optional | OIDC SSO |
|
||||||
| `DISABLE_SIGNUPS` | optional | `true` blocks registration |
|
| `DISABLE_SIGNUPS` | optional | `true` blocks registration; use string `true`/`false` (parsed in `src/env.js`) |
|
||||||
| `CRON_SECRET` | cron route | Protects `/api/cron/generate-recurring` |
|
| `CRON_SECRET` | cron route | Protects `/api/cron/generate-recurring` |
|
||||||
| `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults |
|
| `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults |
|
||||||
|
|
||||||
@@ -181,10 +183,12 @@ Validated in `src/env.js`. See `.env.example`.
|
|||||||
|
|
||||||
| File | Use |
|
| File | Use |
|
||||||
|------|-----|
|
|------|-----|
|
||||||
| `docker-compose.yml` | Production: `app` + `db` (Postgres internal) |
|
| `docker-compose.yml` | Deploy: `app` + `db` (Postgres internal); copy `.env.example` → `.env` |
|
||||||
| `docker-compose.dev.yml` | Dev: Postgres only, port `${POSTGRES_PORT:-5432}` |
|
| `docker-compose.dev.yml` | Local dev: Postgres only, port `${POSTGRES_PORT:-5432}` |
|
||||||
|
|
||||||
App image built from `Dockerfile`; runs `next start` on port 3000.
|
App image built from `Dockerfile`. Container `CMD`: `bun migrate.ts && bun run start` (migrations then `next start` on port 3000).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
# beenvoice documentation
|
# beenvoice-web documentation
|
||||||
|
|
||||||
|
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
|
||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import InvoiceForm from "~/components/forms/invoice-form";
|
import InvoiceForm from "~/components/forms/invoice-form";
|
||||||
|
|
||||||
export default function NewInvoicePage() {
|
export default function NewInvoicePage() {
|
||||||
return <InvoiceForm />;
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<InvoiceForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { api, HydrateClient } from "~/trpc/server";
|
import { api, HydrateClient } from "~/trpc/server";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { Plus, Upload } from "lucide-react";
|
import { FileText, Plus, Upload } from "lucide-react";
|
||||||
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
|
|
||||||
@@ -28,6 +28,12 @@ export default async function InvoicesPage() {
|
|||||||
<span>Import CSV</span>
|
<span>Import CSV</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button asChild variant="outline" className="hover-lift shadow-sm">
|
||||||
|
<Link href="/dashboard/invoices/new?blank=1">
|
||||||
|
<FileText className="mr-2 h-5 w-5" />
|
||||||
|
<span>Blank invoice</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
@@ -95,7 +95,7 @@ function plainTextToHtml(value: string) {
|
|||||||
.replace(/\n/g, "<br>");
|
.replace(/\n/g, "<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultInvoiceFormData(): InvoiceFormData {
|
function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
|
||||||
return {
|
return {
|
||||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
||||||
invoicePrefix: "#",
|
invoicePrefix: "#",
|
||||||
@@ -109,26 +109,30 @@ function createDefaultInvoiceFormData(): InvoiceFormData {
|
|||||||
taxRate: 0,
|
taxRate: 0,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
defaultHourlyRate: null,
|
defaultHourlyRate: null,
|
||||||
items: [
|
items: blank
|
||||||
{
|
? []
|
||||||
id: crypto.randomUUID(),
|
: [
|
||||||
date: new Date(),
|
{
|
||||||
description: "",
|
id: crypto.randomUUID(),
|
||||||
hours: 1,
|
date: new Date(),
|
||||||
rate: 0,
|
description: "",
|
||||||
amount: 0,
|
hours: 1,
|
||||||
},
|
rate: 0,
|
||||||
],
|
amount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const isBlank = searchParams.get("blank") === "1";
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [formData, setFormData] = useState<InvoiceFormData>(
|
const [formData, setFormData] = useState<InvoiceFormData>(() =>
|
||||||
createDefaultInvoiceFormData,
|
createDefaultInvoiceFormData(isBlank),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -368,13 +372,13 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate Items - Check for empty description
|
const itemsToSave = formData.items.filter((item) => item.description?.trim());
|
||||||
|
|
||||||
let invalidItemIndex = -1;
|
let invalidItemIndex = -1;
|
||||||
for (let i = 0; i < formData.items.length; i++) {
|
for (let i = 0; i < formData.items.length; i++) {
|
||||||
if (
|
const item = formData.items[i];
|
||||||
!formData.items[i]?.description ||
|
const desc = item?.description?.trim() ?? "";
|
||||||
formData.items[i]?.description.trim() === ""
|
if (!desc && ((item?.hours ?? 0) > 0 || (item?.rate ?? 0) > 0)) {
|
||||||
) {
|
|
||||||
invalidItemIndex = i;
|
invalidItemIndex = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -421,7 +425,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
emailMessage: formData.emailMessage,
|
emailMessage: formData.emailMessage,
|
||||||
taxRate: formData.taxRate,
|
taxRate: formData.taxRate,
|
||||||
currency: formData.currency,
|
currency: formData.currency,
|
||||||
items: formData.items.map((i) => ({
|
items: itemsToSave.map((i) => ({
|
||||||
date: i.date,
|
date: i.date,
|
||||||
description: i.description,
|
description: i.description,
|
||||||
hours: i.hours,
|
hours: i.hours,
|
||||||
@@ -460,8 +464,18 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
<>
|
<>
|
||||||
<div className="page-enter space-y-6 pb-8">
|
<div className="page-enter space-y-6 pb-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
|
title={
|
||||||
description="Manage your invoice"
|
invoiceId !== "new"
|
||||||
|
? "Edit Invoice"
|
||||||
|
: isBlank
|
||||||
|
? "Blank Invoice"
|
||||||
|
: "Create Invoice"
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
isBlank
|
||||||
|
? "Set up a draft to clock time into later"
|
||||||
|
: "Manage your invoice"
|
||||||
|
}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
{invoiceId !== "new" && (
|
{invoiceId !== "new" && (
|
||||||
|
|||||||
+17
-3
@@ -1,6 +1,20 @@
|
|||||||
import { createEnv } from "@t3-oss/env-nextjs";
|
import { createEnv } from "@t3-oss/env-nextjs";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** Docker/Compose pass booleans as strings; z.coerce.boolean() treats "false" as true. */
|
||||||
|
const optionalEnvBoolean = () =>
|
||||||
|
z
|
||||||
|
.union([z.boolean(), z.string()])
|
||||||
|
.optional()
|
||||||
|
.transform((value) => {
|
||||||
|
if (value === undefined || value === "") return undefined;
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === "true" || normalized === "1") return true;
|
||||||
|
if (normalized === "false" || normalized === "0") return false;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
/**
|
/**
|
||||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||||
@@ -18,8 +32,8 @@ export const env = createEnv({
|
|||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "test", "production"])
|
.enum(["development", "test", "production"])
|
||||||
.default("development"),
|
.default("development"),
|
||||||
DB_DISABLE_SSL: z.coerce.boolean().optional(),
|
DB_DISABLE_SSL: optionalEnvBoolean(),
|
||||||
DISABLE_SIGNUPS: z.coerce.boolean().optional(),
|
DISABLE_SIGNUPS: optionalEnvBoolean(),
|
||||||
CRON_SECRET: z.string().optional(),
|
CRON_SECRET: z.string().optional(),
|
||||||
// SSO / Authentik (optional)
|
// SSO / Authentik (optional)
|
||||||
AUTHENTIK_ISSUER: z.string().url().optional(),
|
AUTHENTIK_ISSUER: z.string().url().optional(),
|
||||||
@@ -37,7 +51,7 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
|
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().optional(),
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().optional(),
|
||||||
NEXT_PUBLIC_AUTHENTIK_ENABLED: z.coerce.boolean().optional(),
|
NEXT_PUBLIC_AUTHENTIK_ENABLED: optionalEnvBoolean(),
|
||||||
NEXT_PUBLIC_BRAND_NAME: z.string().optional(),
|
NEXT_PUBLIC_BRAND_NAME: z.string().optional(),
|
||||||
NEXT_PUBLIC_BRAND_TAGLINE: z.string().optional(),
|
NEXT_PUBLIC_BRAND_TAGLINE: z.string().optional(),
|
||||||
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
|
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
trustedOrigins: [
|
trustedOrigins: [
|
||||||
"https://beenvoice.soconnor.dev",
|
...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []),
|
||||||
|
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
|
||||||
"beenvoice://",
|
"beenvoice://",
|
||||||
"exp://",
|
"exp://",
|
||||||
...(authentikOrigin ? [authentikOrigin] : []),
|
...(authentikOrigin ? [authentikOrigin] : []),
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export const emailRouter = createTRPCRouter({
|
|||||||
throw new Error("Client has no email address");
|
throw new Error("Client has no email address");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!invoice.items.length) {
|
||||||
|
throw new Error("Add at least one line item before sending this invoice");
|
||||||
|
}
|
||||||
|
|
||||||
// Validate email format
|
// Validate email format
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(invoice.client.email)) {
|
if (!emailRegex.test(invoice.client.email)) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const createInvoiceSchema = z.object({
|
|||||||
taxRate: z.number().min(0).max(100).default(0),
|
taxRate: z.number().min(0).max(100).default(0),
|
||||||
currency: z.string().length(3).default("USD"),
|
currency: z.string().length(3).default("USD"),
|
||||||
sendReminderAt: z.date().nullable().optional(),
|
sendReminderAt: z.date().nullable().optional(),
|
||||||
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
items: z.array(invoiceItemSchema).min(0, "Items must be an array"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateInvoiceSchema = createInvoiceSchema.partial().extend({
|
const updateInvoiceSchema = createInvoiceSchema.partial().extend({
|
||||||
@@ -83,6 +83,24 @@ async function verifyBusinessAccess(
|
|||||||
return business;
|
return business;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveBusinessForInvoice(
|
||||||
|
ctx: InvoiceRouterContext,
|
||||||
|
businessId?: string | null,
|
||||||
|
) {
|
||||||
|
if (businessId && businessId.trim() !== "") {
|
||||||
|
return verifyBusinessAccess(ctx, businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [defaultBusiness] = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(businesses)
|
||||||
|
.where(eq(businesses.createdById, ctx.session.user.id))
|
||||||
|
.orderBy(desc(businesses.isDefault), desc(businesses.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return defaultBusiness ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async function verifyClientAccess(ctx: InvoiceRouterContext, clientId: string) {
|
async function verifyClientAccess(ctx: InvoiceRouterContext, clientId: string) {
|
||||||
const client = await ctx.db.query.clients.findFirst({
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
where: eq(clients.id, clientId),
|
where: eq(clients.id, clientId),
|
||||||
@@ -349,14 +367,16 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.insert(invoiceItems).values(
|
if (items.length > 0) {
|
||||||
items.map((item, idx) => ({
|
await tx.insert(invoiceItems).values(
|
||||||
...item,
|
items.map((item, idx) => ({
|
||||||
invoiceId: invoice.id,
|
...item,
|
||||||
amount: item.hours * item.rate,
|
invoiceId: invoice.id,
|
||||||
position: idx,
|
amount: item.hours * item.rate,
|
||||||
})),
|
position: idx,
|
||||||
);
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return invoice;
|
return invoice;
|
||||||
});
|
});
|
||||||
@@ -475,14 +495,16 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
|
await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
|
||||||
|
|
||||||
await tx.insert(invoiceItems).values(
|
if (items.length > 0) {
|
||||||
items.map((item, idx) => ({
|
await tx.insert(invoiceItems).values(
|
||||||
...item,
|
items.map((item, idx) => ({
|
||||||
invoiceId: id,
|
...item,
|
||||||
amount: item.hours * item.rate,
|
invoiceId: id,
|
||||||
position: idx,
|
amount: item.hours * item.rate,
|
||||||
})),
|
position: idx,
|
||||||
);
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const [updatedInvoice] = await tx
|
const [updatedInvoice] = await tx
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
@@ -658,7 +680,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
: null;
|
: null;
|
||||||
const [client, business, settings] = await Promise.all([
|
const [client, business, settings] = await Promise.all([
|
||||||
verifyClientAccess(ctx, input.clientId),
|
verifyClientAccess(ctx, input.clientId),
|
||||||
verifyBusinessAccess(ctx, businessId),
|
resolveBusinessForInvoice(ctx, businessId),
|
||||||
ctx.db.query.platformSettings.findFirst({
|
ctx.db.query.platformSettings.findFirst({
|
||||||
where: eq(platformSettings.id, "global"),
|
where: eq(platformSettings.id, "global"),
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user