Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'

git-subtree-dir: apps/web
git-subtree-mainline: 068a51b46b
git-subtree-split: 1e7174fa60
This commit is contained in:
2026-08-16 21:42:59 -04:00
350 changed files with 62192 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "beenvoice-dev",
"runtimeExecutable": "bun",
"runtimeArgs": ["dev"],
"port": 3000,
"autoPort": false
}
]
}
+19
View File
@@ -0,0 +1,19 @@
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
+174
View File
@@ -0,0 +1,174 @@
# =============================================================================
# beenvoice-web — environment template
# =============================================================================
#
# Quick start (local dev):
# cp .env.example .env.local
# docker compose -f docker-compose.dev.yml up -d # Postgres + Garage
# bun run db:push # or: bun run db:migrate
# bun run dev
# Garage S3 API: http://localhost:3900
#
# Quick start (Docker app + Postgres):
# cp .env.example .env
# # edit AUTH_SECRET + public URLs below
# ./scripts/docker-deploy.sh
#
# -----------------------------------------------------------------------------
# Build-time vs runtime (Docker)
# -----------------------------------------------------------------------------
#
# Baked into the image at `docker compose build` (rebuild after changes):
# NEXT_PUBLIC_APP_URL
# NEXT_PUBLIC_* branding / theme defaults
# NEXT_PUBLIC_AUTHENTIK_ENABLED
# NEXT_PUBLIC_UMAMI_*
#
# Read from .env when the container starts (restart app after changes):
# AUTH_SECRET, BETTER_AUTH_URL, DATABASE_URL (compose overrides host),
# RESEND_*, DISABLE_SIGNUPS, AUTHENTIK_* secrets, CRON_SECRET
#
# `NEXT_PUBLIC_APP_URL` should still match your public browser URL for SSR,
# emails, and MCP links. In the browser, sign-in uses the current page origin
# automatically so dev works when Next picks another port (e.g. 3002).
#
# Updating production: git pull && ./scripts/docker-deploy.sh
# (or: docker compose up -d --build). Plain `docker compose up -d` does NOT rebuild.
# Migrations run on every app start (idempotent — only pending SQL is applied).
# =============================================================================
# Core — required
# =============================================================================
# PostgreSQL connection string.
# Local dev (docker-compose.dev.yml): host is localhost
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
# Session signing secret. Required in production.
# Generate: openssl rand -base64 32
AUTH_SECRET=change-me-generate-a-real-secret
# Public URL users open in the browser (scheme + host + port if non-standard).
# Must match how you access the app for cookies, OAuth callbacks, and email links.
BETTER_AUTH_URL=http://localhost:3000
# Same as BETTER_AUTH_URL in most setups. Embedded in the client bundle at build.
NEXT_PUBLIC_APP_URL=http://localhost:3000
# =============================================================================
# Local development
# =============================================================================
NODE_ENV=development
# Set true when connecting to local Postgres without SSL (default for compose).
DB_DISABLE_SSL=true
# Dev-only: host ports for `docker compose -f docker-compose.dev.yml`.
POSTGRES_PORT=5432
GARAGE_API_PORT=3900
# Optional: if Next dev picks another port, you do not need to change URLs for
# sign-in — the auth client uses window.location.origin in the browser.
# =============================================================================
# Docker Compose (app + database)
# =============================================================================
# Host port mapped to container :3000 (WEB_PORT, then PORT, then 3000).
WEB_PORT=3000
# App image tag for docker-compose.yml (optional). docker-deploy.sh sets
# beenvoice:<git-sha> automatically; default without it is beenvoice:local.
# BEENVOICE_IMAGE=beenvoice:local
# Postgres credentials for docker-compose.yml `db` service.
# DATABASE_URL inside the app container is set by compose (host `db`, not localhost).
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=postgres
# =============================================================================
# White-label defaults (optional)
# =============================================================================
# Baked in at Docker build. After first deploy, admins can override many of
# Optional white-label defaults (build-time). Users choose light/dark in Settings.
NEXT_PUBLIC_BRAND_NAME=beenvoice
NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses
NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
NEXT_PUBLIC_BRAND_ICON=$
# =============================================================================
# Email — Resend (optional)
# =============================================================================
# Leave blank to disable invoice and password-reset email delivery.
RESEND_API_KEY=
RESEND_DOMAIN=
# =============================================================================
# Analytics — Umami (optional)
# =============================================================================
# Leave website ID blank to disable.
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
# =============================================================================
# Access control (optional)
# =============================================================================
# Block new email/password registrations (default: true / signups off).
# Set DISABLE_SIGNUPS=false to allow new email/password signups.
# DISABLE_SIGNUPS=false
# Bearer token for POST /api/cron/generate-recurring (recurring invoice cron).
# CRON_SECRET=
# =============================================================================
# Receipt storage — S3-compatible (optional)
# =============================================================================
# When S3_BUCKET + S3_ACCESS_KEY + S3_SECRET_KEY are unset, receipts land in
# .data/receipts/ (dev-friendly). Works with AWS S3, Garage, Cloudflare R2, etc.
#
# S3_ENDPOINT — who can reach Garage?
# • Host dev (bun dev + docker-compose.dev.yml Garage on the host): localhost:3900
# • App in Docker (docker-compose.yml): http://garage:3900 (Compose service name)
# • Coolify — see docs/COOLIFY.md. Summary:
# - Best: one Compose resource with docker-compose.coolify.yml (app+db+garage).
# - Application + separate Garage: ENOTFOUND garage → set S3_ENDPOINT to
# SERVICE_URL_GARAGE_3900 (public domain) OR http://garage-<resource-uuid>:3900
# with Connect to Predefined Network on both resources. Never bare "garage".
# - NEVER use localhost in production — inside the app container that is the app, not Garage.
#
# Local dev with docker-compose.dev.yml Garage (host `bun dev`):
S3_ENDPOINT=http://localhost:3900
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=GK3515373e4c851ebaad366558
S3_SECRET_KEY=7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34
S3_REGION=garage
# S3_FORCE_PATH_STYLE=true # default on when S3_ENDPOINT is set; required for Garage/HTTPS proxy
#
# docker-compose.yml sets S3_ENDPOINT=http://garage:3900 inside the app container
# automatically. S3_ACCESS_KEY / S3_SECRET_KEY must match the garage service env.
# =============================================================================
# SSO — Authentik OIDC (optional)
# =============================================================================
# Set NEXT_PUBLIC_AUTHENTIK_ENABLED=true and rebuild the image to show SSO on
# sign-in. Server secrets are runtime-only (no rebuild needed for secrets).
NEXT_PUBLIC_AUTHENTIK_ENABLED=false
AUTHENTIK_ISSUER=
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
# Optional extra trusted origin for better-auth (defaults derived from issuer).
AUTHENTIK_ORIGIN=
# =============================================================================
# Advanced / CI (usually unset)
# =============================================================================
# Skip Zod env validation during `next build` (set automatically in Dockerfile).
# SKIP_ENV_VALIDATION=1
+46
View File
@@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
db.sqlite
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
.data/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env.prod
.env*.local
.env*.production
# typescript
*.tsbuildinfo
# idea files
.idea
+461
View File
@@ -0,0 +1,461 @@
# 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.
## 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)
## Core Development Principles
### 1. Business-First Approach
- **Priority**: Reliability and security over flashy features
- **User Experience**: Professional, clean, and intuitive interface
- **Data Integrity**: Always validate and sanitize user input
- **Error Handling**: Graceful degradation with clear user feedback
### 2. Type Safety & Code Quality
- **TypeScript**: Use strict TypeScript for all new code
- **tRPC**: All API calls must go through tRPC for type safety
- **Validation**: Use Zod schemas for all input validation
- **Error Boundaries**: Implement proper error handling at all levels
## Tech Stack Guidelines
### Frontend (Next.js 16 + App Router)
- Use App Router patterns consistently
- Implement proper loading states and error boundaries
- Use React Server Components where appropriate
### Backend (tRPC + Drizzle)
- All business logic goes through tRPC routers
- Use Drizzle ORM for all database operations
- Implement proper transactions for multi-table operations
- Follow existing router patterns in `src/server/api/routers/`
### Database (PostgreSQL)
- Use Drizzle migrations in `drizzle/`; journal must stay in sync (`drizzle/meta/_journal.json`)
- `db:push` for local iteration; Docker runs `migrate.ts` on startup
- Follow existing schema patterns in `src/server/db/schema.ts`
### Authentication (better-auth)
- Email/password via better-auth + custom `/api/auth/register` REST
- Optional Authentik OIDC (`AUTHENTIK_*`); Expo plugin for mobile
- Route protection via `src/proxy.ts` (session cookie check) and `protectedProcedure`
- `DISABLE_SIGNUPS=true` blocks registration; env booleans parsed in `src/env.js` (not `z.coerce.boolean`)
### Development Tools
- Use ESLint and Prettier for code formatting
- Use TypeScript for type safety
- Exclusively use bun for development and production. Do not use Node.js or Deno.
- Stay away from starting development servers or running builds unless absolutely necessary.
- Run lints and typechecks when helpful.
## Component Architecture
### UI Components (shadcn/ui)
- Use shadcn/ui components as the foundation
- Follow existing component patterns
- Use `cn()` utility for conditional className merging
- Maintain consistent spacing (4px grid system)
### Component Organization
- **Base UI Components**: `src/components/ui/` - Pure, portable shadcn/ui components
- **Project Components**: `src/components/` - Project-specific reusable components
- **Page Components**: `src/app/_components/` - Page-specific components
### UI Component Rules
#### What Belongs in `src/components/ui/` (Portable)
- **Pure shadcn/ui components**: button, input, select, dialog, etc.
- **Generic layout components**: page-layout, card, table
- **Basic form components**: input, textarea, checkbox, switch
- **Navigation components**: breadcrumb, navigation-menu
- **Feedback components**: badge, alert-dialog, toast
- **Data display**: table, skeleton, progress
- **Overlay components**: dialog, sheet, popover, dropdown-menu
#### What Should Move to `src/components/` (Project-Specific)
- **Business logic components**: address-form, status-badge, data-table
- **Domain-specific forms**: client-form, invoice-form, business-form
- **Custom layouts**: page-header, dashboard-breadcrumbs
- **Feature components**: invoice-list, client-list, editable-invoice-items
- **Navigation**: Sidebar, Navbar, navigation
- **Branding**: logo, AddressAutocomplete
#### Immediate Reorganization Needed
**Move from `src/components/ui/` to `src/components/forms/`:**
- `address-form.tsx` - Business-specific address handling
- `file-upload.tsx` - Project-specific file upload logic
**Move from `src/components/ui/` to `src/components/data/`:**
- `data-table.tsx` - Enhanced with business logic
- `status-badge.tsx` - Invoice status specific
- `stats-card.tsx` - Dashboard-specific statistics
**Move from `src/components/ui/` to `src/components/layout/`:**
- `page-layout.tsx` - Project-specific layout patterns
- `quick-action-card.tsx` - Dashboard-specific actions
- `floating-action-bar.tsx` - Project-specific floating actions
**Keep in `src/components/ui/` (Portable):**
- `button.tsx`, `input.tsx`, `select.tsx` - Pure shadcn/ui
- `dialog.tsx`, `sheet.tsx`, `popover.tsx` - Generic overlays
- `table.tsx`, `card.tsx`, `badge.tsx` - Base components
- `calendar.tsx`, `date-picker.tsx` - Generic date components
- `dropdown-menu.tsx`, `navigation-menu.tsx` - Generic navigation
- `skeleton.tsx`, `progress.tsx` - Generic loading states
#### Component Design Principles
- **High Reusability**: Components should accept props for customization
- **Composition over Inheritance**: Use children props and render props
- **Default Values**: Provide sensible defaults for all optional props
- **Type Safety**: Use TypeScript interfaces for all props
- **Accessibility**: Include proper ARIA labels and keyboard navigation
- **Responsive Design**: Mobile-first approach with responsive variants
#### Component Props Pattern
```typescript
interface ComponentProps {
// Required props
title: string;
// Optional props with defaults
variant?: "default" | "success" | "warning" | "error";
size?: "sm" | "md" | "lg";
// Styling props
className?: string;
// Event handlers
onClick?: () => void;
onChange?: (value: string) => void;
// Content
children?: React.ReactNode;
// Accessibility
"aria-label"?: string;
}
```
#### Component Reusability Guidelines
- **Configurable Content**: Use props for text, labels, and content
- **Flexible Styling**: Accept className and style props for customization
- **Variant System**: Use variant props for different visual states
- **Size Variants**: Provide consistent size options (sm, md, lg, xl)
- **Icon Support**: Accept icon props for visual customization
- **Loading States**: Include loading/skeleton variants
- **Error States**: Handle error states gracefully
- **Empty States**: Provide empty state components
#### Component Customization Examples
```typescript
// Good: Highly customizable component
interface DataTableProps<TData> {
columns: ColumnDef<TData>[];
data: TData[];
title?: string;
description?: string;
actions?: React.ReactNode;
searchKey?: string;
searchPlaceholder?: string;
showPagination?: boolean;
pageSize?: number;
className?: string;
emptyState?: React.ReactNode;
loading?: boolean;
}
// Good: Flexible form component
interface FormFieldProps {
label: string;
name: string;
type?: "text" | "email" | "password" | "number";
placeholder?: string;
required?: boolean;
error?: string;
className?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
disabled?: boolean;
onChange?: (value: string) => void;
}
```
#### Component File Structure
```
src/components/
├── ui/ # Portable base components
│ ├── button.tsx # Pure shadcn/ui components
│ ├── input.tsx
│ └── ...
├── forms/ # Project-specific forms
│ ├── address-form.tsx
│ ├── client-form.tsx
│ └── invoice-form.tsx
├── layout/ # Layout components
│ ├── page-header.tsx
│ ├── sidebar.tsx
│ └── navbar.tsx
├── data/ # Data display components
│ ├── data-table.tsx
│ ├── status-badge.tsx
│ └── invoice-list.tsx
├── navigation/ # Navigation components
│ ├── breadcrumbs.tsx
│ └── navigation.tsx
└── branding/ # Brand-specific components
├── logo.tsx
└── address-autocomplete.tsx
```
### Styling Guidelines
- **Primary Color**: Green (#16a34a) for branding
- **Font**: Geist font family for professional typography
- **Tailwind CSS**: Use utility classes consistently
- **Responsive Design**: Mobile-first approach
## Business Logic Patterns
### Invoice Management
- **Invoice Creation**: Multi-step process with validation
- **Line Items**: Flexible pricing with custom rates
- **Status Tracking**: draft → sent → paid/overdue
- **PDF Generation**: Professional invoice formatting
### Client Management
- **Contact Information**: Complete address and contact details
- **Search & Filter**: Efficient client lookup
- **Data Validation**: Proper email and phone validation
### Business Profile
- **Default Business**: One default business per user
- **Logo Support**: Professional branding
- **Tax Information**: Business tax details
## API Development Rules
### tRPC Router Patterns
```typescript
// Follow this pattern for all routers
export const exampleRouter = createTRPCRouter({
create: protectedProcedure
.input(z.object({ /* validation schema */ }))
.mutation(async ({ ctx, input }) => {
// Business logic here
}),
list: protectedProcedure
.input(z.object({ /* pagination/filtering */ }))
.query(async ({ ctx, input }) => {
// Query logic here
}),
});
```
### Error Handling
- Use toast notifications for user feedback
- Implement proper form validation
- Handle API errors gracefully
- Provide clear error messages
### Security
- Always validate user input with Zod
- Check user permissions for all operations
- Sanitize data before database operations
- Use proper authentication checks
## Database Schema Rules
### Table Structure
- **UUID Primary Keys**: Use `crypto.randomUUID()` for all IDs
- **Timestamps**: Include `createdAt` and `updatedAt` fields
- **User Relations**: All business data linked to users
- **Indexes**: Proper indexing for performance
### Migrations
When adding a new migration:
1. Create the SQL file in `drizzle/` following the numbering sequence (e.g. `0011_my_change.sql`)
2. **Always update `drizzle/meta/_journal.json`** to include the new entry — Drizzle's migrate runner uses this file to determine which migrations to apply. If the entry is missing, the migration will be silently skipped on deploy.
The journal entry format:
```json
{
"idx": 10,
"version": "7",
"when": 1780704000000,
"tag": "0010_my_change",
"breakpoints": true
}
```
Use a Unix timestamp in milliseconds for `when`, incrementing `idx` by 1 from the previous entry.
Migrations run automatically at container startup via `bun migrate.ts` (see Dockerfile `CMD`). Do not run them manually.
### Relationships
- **Users → Clients**: One-to-many
- **Users → Businesses**: One-to-many
- **Users → Invoices**: One-to-many
- **Clients → Invoices**: One-to-many
- **Businesses → Invoices**: One-to-many
- **Invoices → Invoice Items**: One-to-many
## File Naming Conventions
### Components & Pages
- **Components**: PascalCase (e.g., `ClientList.tsx`)
- **Pages**: kebab-case (e.g., `new-client.tsx`)
- **Layouts**: `layout.tsx` (Next.js convention)
- **API Routes**: `route.ts` (Next.js convention)
### Utilities & Helpers
- **Utilities**: camelCase (e.g., `formatCurrency.ts`)
- **Constants**: UPPER_SNAKE_CASE
- **Types**: PascalCase (e.g., `InvoiceStatus`)
## Development Workflow
### Adding New Features
1. **Database Schema**: Update schema with migrations
2. **tRPC Router**: Add procedures with validation
3. **UI Components**: Create components using shadcn/ui
4. **Pages**: Implement pages with proper routing
5. **Testing**: Verify functionality and error handling
### Code Quality
- **ESLint**: Follow existing linting rules
- **Prettier**: Consistent code formatting
- **TypeScript**: Strict type checking
- **Performance**: Optimize database queries and React components
## Business Logic Specifics
### Invoice Calculations
- **Subtotal**: Sum of all line items
- **Tax**: Apply tax rate to subtotal
- **Total**: Subtotal + tax
- **Currency**: Handle decimal precision properly
### Status Management
- **Draft**: Initial state, editable
- **Sent**: Invoice sent to client
- **Paid**: Payment received
- **Overdue**: Past due date
### Data Validation
- **Email**: Proper email format validation
- **Phone**: International phone number support
- **Address**: Complete address validation
- **Currency**: Proper decimal handling
## Performance Guidelines
### Database Optimization
- Use proper indexes for frequently queried fields
- Implement pagination for large datasets
- Use transactions for data consistency
- Optimize query patterns
### Frontend Performance
- Use React.memo for expensive components
- Implement proper loading states
- Optimize bundle size
- Use Next.js Image component for images
### Caching Strategy
- Use React Query for client-side caching
- Implement proper cache invalidation
- Use Next.js caching where appropriate
## Security Considerations
### Authentication & Authorization
- All routes require proper authentication
- Check user ownership for all operations
- Implement proper session management
- Use secure password hashing
### Data Protection
- Validate all user inputs
- Sanitize data before database operations
- Implement proper error handling
- Use HTTPS in production
### Business Data Security
- User data isolation
- Proper access controls
- Audit trails for sensitive operations
- Secure API endpoints
## Testing & Quality Assurance
### Manual Testing Checklist
- [ ] Authentication flows work correctly
- [ ] Form validation provides clear feedback
- [ ] Responsive design on all screen sizes
- [ ] Database operations handle errors gracefully
- [ ] PDF generation works correctly
- [ ] Navigation and routing function properly
### Code Review Guidelines
- Check for proper error handling
- Verify type safety
- Ensure consistent styling
- Review security implications
- Test business logic accuracy
## Deployment & Production
### Environment Configuration
- Use proper environment variables (see `.env.example` and `src/env.js`)
- `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` must match the public hostname
- Secure database connections; `DB_DISABLE_SSL=true` for compose Postgres
### Database Management
- Use migrations for schema changes
- Backup data regularly
- Monitor database performance
- Handle database errors gracefully
## Common Patterns & Anti-Patterns
### ✅ Do's
- Use tRPC for all API calls
- Implement proper loading states
- Use toast notifications for feedback
- Follow existing component patterns
- Validate all user inputs
- Use proper TypeScript types
### ❌ Don'ts
- Don't use direct fetch calls
- Don't skip input validation
- Don't ignore error handling
- Don't hardcode business logic
- Don't use any types unnecessarily
- Don't skip proper authentication checks
## Emergency Procedures
### Critical Issues
- **Data Loss**: Immediate database backup
- **Security Breach**: Rotate all secrets
- **Performance Issues**: Database query optimization
- **User Complaints**: Prioritize user experience fixes
### Rollback Strategy
- Keep database migrations reversible
- Maintain version control for all changes
- Test rollback procedures regularly
- Document emergency procedures
## Remember
This is a business application where reliability, security, and professional user experience are critical. Every decision should prioritize these values over development convenience or flashy features.
- Don't create demo pages unless absolutely necessary.
- Don't create unnecessary complexity.
- Don't run builds unless absolutely necessary, if you do, kill the dev servers.
- Don't start new dev servers unless asked.
- Don't start drizzle studio- you cannot do anything with it.
+49
View File
@@ -0,0 +1,49 @@
# 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"]
+280
View File
@@ -0,0 +1,280 @@
![beenvoice Logo](public/beenvoice-logo.png)
# 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)
## Stack
| Layer | Technology |
|-------|------------|
| App | Next.js 16 App Router, React 19 |
| API | tRPC 11 + SuperJSON |
| Database | PostgreSQL 17, Drizzle ORM |
| Auth | better-auth (email/password, optional Authentik OIDC, Expo mobile) |
| UI | shadcn/ui, Tailwind CSS v4 |
| Email / PDF | Resend, `@react-pdf/renderer` |
| Runtime | Bun |
## Features
- Clients, businesses, invoices (line items, tax, status workflow)
- Time clock with one running timer per user; clock-out can append invoice lines
- Expenses, payments, recurring invoices, invoice templates
- PDF export and email delivery (Resend)
- Public invoice links (`/i/[token]`)
- CSV import, reports, platform branding / admin settings
- MCP API (`/api/mcp`) for automation via API keys (`bv_…`)
- Optional Authentik OIDC SSO
## Prerequisites
- [Bun](https://bun.sh) 1.x
- Docker & Docker Compose (for PostgreSQL locally or full-stack deploy)
- Git
## Local development
### 1. Clone and install
```bash
git clone https://git.soconnor.dev/soconnor/beenvoice-web.git
cd beenvoice-web
bun install
```
### 2. Environment
```bash
cp .env.example .env.local
```
Edit `.env.local` for local dev. Minimum:
```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
docker compose -f docker-compose.dev.yml up -d
```
After a fresh volume (`docker compose down -v`), Postgres starts empty — you must apply schema before registering or signing in.
Apply schema (pick one):
```bash
bun run db:push # fast iteration during development
# bun run db:migrate # same migrations the Docker image runs in production
```
**Demo account.** For App Store review and local testing, `bun run db:migrate` creates a pre-populated `demo@example.com` account (`db:push` does not) with the public password `demo123`.
To rotate the credential temporarily, provision a private password:
```bash
DEMO_ACCOUNT_PASSWORD='<private 12+ character password>' bun run demo:provision
```
Provisioning rotates the credential and invalidates prior sessions. Do not commit or publish a private replacement password. The account includes a sample business, clients, and invoices (draft, sent, and paid).
### 4. Run
```bash
bun run dev
```
Open [http://localhost:3000](http://localhost:3000), register at `/auth/register`, or sign in with the demo account above.
## Docker deployment (app + database)
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.
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.
### 1. Configure
```bash
cp .env.example .env
```
Set at least:
```env
AUTH_SECRET=<openssl rand -base64 32>
BETTER_AUTH_URL=https://your-public-hostname
NEXT_PUBLIC_APP_URL=https://your-public-hostname
```
`BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` must match the URL users actually use in the browser (scheme + host + port). If they point at `localhost` but you access the app via another hostname, sign-up and sign-in will fail (often with a vague **"REQUIRED"** toast).
`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
```
`BETTER_AUTH_URL` and `AUTH_SECRET` are read at **container runtime** from `.env` — you can change them without rebuilding, then restart the app container.
### 2. First start (or after code changes)
```bash
./scripts/docker-deploy.sh
# or: bun run docker:deploy
# or: 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.
App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network.
### Scheduled recurring invoices
The app container does not run a cron daemon. It starts the web server with
`bun migrate.ts && bun run start`, and recurring invoice generation only happens
when something calls `POST /api/cron/generate-recurring` with
`Authorization: Bearer $CRON_SECRET`.
- **Coolify deploys:** use a Coolify scheduled task to call the endpoint.
- **Full Docker deploys:** use host cron, a small scheduler sidecar, or an
external scheduler to call
`http://localhost:${WEB_PORT:-${PORT:-3000}}/api/cron/generate-recurring`.
### 3. Updating an existing deploy
```bash
git pull
./scripts/docker-deploy.sh # recommended: rebuild + tag with git SHA + restart
# or: docker compose up -d --build
```
| Command | New code? | Migrations run? |
|---------|-----------|-----------------|
| `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 |
| `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).
To verify migration files match the journal before deploy: `bun run db:verify-journal`.
### Coolify
For self-hosted [Coolify](https://coolify.io) deploys (especially `ENOTFOUND garage` with Application + separate Garage compose), see **[docs/COOLIFY.md](./docs/COOLIFY.md)**. Recommended: deploy [`docker-compose.coolify.yml`](./docker-compose.coolify.yml) as a single Compose resource.
### 4. Sign-ups
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.
### 5. 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 + 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
```
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
bun run docker:up # dev Postgres only (Colima + docker-compose.dev.yml)
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`.
## 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/COOLIFY.md](./docs/COOLIFY.md) | Coolify deploy paths and Garage networking |
| [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).
+1938
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"iconLibrary": "lucide"
}
@@ -0,0 +1,74 @@
# Garage-only stack for Coolify when beenvoice runs as a separate Application resource.
#
# Deploy: Coolify → Docker Compose → compose file: docker-compose.coolify-garage.yml
#
# ── Pair with a beenvoice Application (pick ONE) ───────────────────────────────
#
# A) Public Garage URL (most reliable — no shared Docker network required)
# 1. Redeploy this stack (includes SERVICE_FQDN_GARAGE_3900 below).
# 2. Garage resource → assign a domain for port 3900 (e.g. s3.example.com).
# 3. Copy SERVICE_URL_GARAGE_3900 from this resource's Environment tab.
# 4. beenvoice Application → S3_ENDPOINT=<that URL> → redeploy beenvoice.
#
# B) Internal Docker DNS (same Coolify destination network)
# 1. Garage resource → Advanced → enable "Connect to Predefined Network" → redeploy.
# 2. beenvoice Application → same destination → enable "Connect to Predefined Network".
# 3. beenvoice → S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900
#
# Recommended long-term: deploy docker-compose.coolify.yml as one stack (app+db+garage).
# See docs/COOLIFY.md.
services:
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
SERVICE_FQDN_GARAGE_3900:
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
expose:
- "3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "rpc_secret_change_me_in_production"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+124
View File
@@ -0,0 +1,124 @@
# beenvoice on Coolify — single Docker Compose resource (recommended).
#
# Deploy: Coolify → New Resource → Docker Compose → compose file: docker-compose.coolify.yml
#
# 1. Assign a domain to the `app` service in Coolify (SERVICE_FQDN_APP wires Traefik).
# 2. Set AUTH_SECRET, POSTGRES_PASSWORD, S3_ACCESS_KEY, S3_SECRET_KEY in the resource env (see .env.example).
# 3. Do NOT override S3_ENDPOINT — this stack sets http://garage:3900 on the shared network.
# 4. Rebuild after changing NEXT_PUBLIC_* (image build args use SERVICE_URL_APP).
#
# Migrating from Application + separate Postgres + Garage compose:
# - Export Postgres data, point DATABASE_URL at this stack's `db` service, redeploy once here.
# - Or keep external Postgres and remove the `db` service + volume from this file.
services:
app:
build:
context: .
args:
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:3000}}
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:3000}}
image: ${BEENVOICE_IMAGE:-beenvoice:coolify}
environment:
SERVICE_FQDN_APP:
NODE_ENV: production
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in Coolify env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
BETTER_AUTH_URL: ${SERVICE_URL_APP:-${BETTER_AUTH_URL:-http://localhost:3000}}
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_APP:-${NEXT_PUBLIC_APP_URL:-http://localhost:3000}}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-true}
CRON_SECRET: ${CRON_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
S3_ENDPOINT: http://garage:3900
S3_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-garage}
expose:
- "3000"
depends_on:
db:
condition: service_healthy
garage:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
garage:
image: dxflrs/garage:v2.3.0
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
SERVICE_FQDN_GARAGE_3900:
configs:
- source: garage_config
target: /etc/garage.toml
volumes:
- beenvoice_garage_meta:/var/lib/garage/meta
- beenvoice_garage_data:/var/lib/garage/data
command: ["/garage", "server", "--single-node", "--default-bucket"]
expose:
- "3900"
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
restart: unless-stopped
volumes:
beenvoice_pg_data:
beenvoice_garage_meta:
beenvoice_garage_data:
configs:
garage_config:
content: |
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "rpc_secret_change_me_in_production"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
+74
View File
@@ -0,0 +1,74 @@
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"
+123
View File
@@ -0,0 +1,123 @@
# 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 (~50100 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"
+217
View File
@@ -0,0 +1,217 @@
# 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)
## Stack
| Layer | Technology |
|-------|------------|
| Framework | Next.js 16 App Router (`src/app/`) |
| API | tRPC 11 (`/api/trpc`), SuperJSON transformer |
| ORM | Drizzle + `pg` pool |
| Auth | better-auth (email/password, optional Authentik OIDC, Expo plugin for mobile) |
| UI | shadcn/ui, Tailwind CSS v4, Radix primitives |
| Email | Resend |
| PDF | `@react-pdf/renderer` |
## Request flow
```
Browser / Mobile / MCP client
├─► /api/auth/* → better-auth handler (session cookies)
├─► /api/trpc/* → createContext() → appRouter
│ ├─ Bearer / x-api-key → api-key auth
│ └─ else → better-auth session
├─► /api/mcp → API key only → JSON-RPC tools → tRPC caller
├─► /api/i/[token]/pdf → public invoice PDF
└─► /dashboard/* → RSC + client components (session required in UI)
```
**Context** (`src/server/api/trpc.ts`): `protectedProcedure` requires `ctx.session.user`. API-key auth sets `authSource: "api-key"`; `apiKeys.*` mutations require session (cannot manage keys with a key).
## Directory layout
```
src/
├── app/ # Routes (pages + route handlers)
│ ├── api/
│ │ ├── auth/ # better-auth catch-all + custom register/reset REST
│ │ ├── trpc/[trpc]/ # tRPC HTTP adapter
│ │ ├── mcp/ # MCP over HTTP (API key)
│ │ ├── i/[token]/pdf/ # Public PDF
│ │ └── cron/ # Recurring invoice generation (CRON_SECRET)
│ ├── auth/ # sign-in, register, forgot/reset password
│ ├── dashboard/ # Authenticated app shell
│ └── i/[token]/ # Public invoice view
├── components/ # Shared UI (ui/, forms/, layout/, data/)
├── hooks/
├── lib/ # auth.ts, pdf-export, email templates, branding
├── server/
│ ├── api/
│ │ ├── root.ts # appRouter composition
│ │ ├── trpc.ts # procedures, context, timing middleware (dev)
│ │ ├── api-keys.ts
│ │ └── routers/ # one file per domain
│ └── db/
│ ├── schema.ts # all tables (prefix beenvoice_)
│ ├── index.ts # drizzle + pool
│ └── migrate.ts
├── trpc/ # react.tsx (client), server.ts (RSC)
├── env.js # @t3-oss/env-nextjs validation
└── styles/globals.css
drizzle/ # SQL migrations (00000014+)
```
## tRPC routers
Root: `src/server/api/root.ts`. All routers use Zod input validation.
| Namespace | File | Key procedures |
|-----------|------|----------------|
| `clients` | `routers/clients.ts` | getAll, getById, create, update, delete |
| `businesses` | `routers/businesses.ts` | getAll, getById, getDefault, create, update, delete, setDefault, getEmailConfig, updateEmailConfig |
| `invoices` | `routers/invoices.ts` | getAll, getBillable, getById, create, update, delete, updateStatus, bulk*, previewPdf, public token, **getByPublicToken** (public), sendReminder |
| `payments` | `routers/payments.ts` | getByInvoice, create, delete |
| `expenses` | `routers/expenses.ts` | getAll, getById, create, update, delete |
| `invoiceTemplates` | `routers/invoiceTemplates.ts` | CRUD by template type |
| `recurringInvoices` | `routers/recurring-invoices.ts` | CRUD, pause/resume, generateNow; cron helper `generateDueRecurringInvoices` |
| `timeEntries` | `routers/time-entries.ts` | getAll, getRunning, clockIn, updateRunning, clockOut, create, update, delete, getSummary |
| `dashboard` | `routers/dashboard.ts` | getStats |
| `email` | `routers/email.ts` | sendInvoice |
| `settings` | `routers/settings.ts` | profile, theme, animation prefs, export/import data, admin account roles |
| `apiKeys` | `routers/apiKeys.ts` | list, create, revoke (session-only) |
### Time clock semantics
- **One running entry per user** — partial unique index on `(createdById)` where `endedAt IS NULL`.
- `clockIn` — optional client, invoice, rate, backdated `startedAt`; resolves rate from input → client default → business default.
- `clockOut` — optional description update; computes hours; if `invoiceId` set, appends line item; else tries latest open invoice for client.
- Outcomes: `linked_to_invoice`, `saved_no_invoice`, `saved_no_client`, `zero_hours`.
## Database schema
Single file: `src/server/db/schema.ts`. Table names use `pgTableCreator` → prefix `beenvoice_`.
### Auth & platform
| Table | Notes |
|-------|-------|
| `beenvoice_user` | Core user; role for admin features |
| `beenvoice_account` | OAuth/credential accounts (better-auth) |
| `beenvoice_session` | Sessions; unique token |
| `beenvoice_verification_token` | Email verification / reset |
| `beenvoice_api_key` | `bv_` prefix keys; SHA-256 hash stored |
| `beenvoice_sso_provider` | OIDC/SAML config per user |
| `beenvoice_platform_setting` | Singleton (`id = global`) branding/PDF/appearance |
### Domain
| Table | FKs | Notes |
|-------|-----|-------|
| `beenvoice_client` | `createdById` → user | defaultHourlyRate, currency |
| `beenvoice_business` | `createdById` | Resend config, `isDefault` |
| `beenvoice_invoice` | client, business?, user | status draft/sent/paid; `publicToken` |
| `beenvoice_invoice_item` | invoice (cascade) | position ordering |
| `beenvoice_invoice_payment` | invoice, user | payment method enum |
| `beenvoice_expense` | business?, client?, invoice? | billable flags |
| `beenvoice_invoice_template` | user | notes/terms templates |
| `beenvoice_recurring_invoice` | client, business?, user | schedule, `nextDueAt` |
| `beenvoice_recurring_invoice_item` | recurring (cascade) | |
| `beenvoice_time_entry` | client?, invoice?, user | `endedAt` null = running |
Migrations: `bun run db:generate``drizzle/`; apply with `db:push` (dev) or `db:migrate` (prod script).
## Authentication
**Server**`src/lib/auth.ts`:
- `betterAuth` + `drizzleAdapter` (users, sessions, accounts, verification)
- Plugins: `@better-auth/expo` (mobile SecureStore cookies), `nextCookies()`, optional `genericOAuth` (Authentik)
- Email/password with bcrypt (12 rounds); `DISABLE_SIGNUPS=true` blocks registration (custom `/api/auth/register` and better-auth `disableSignUp`)
- `trustedOrigins`: `BETTER_AUTH_URL`, `NEXT_PUBLIC_APP_URL`, `beenvoice://`, `exp://`, plus Authentik origin when configured
**Web client**`src/lib/auth-client.ts`: `createAuthClient` + `genericOAuthClient`.
**Routes**:
- `src/app/api/auth/[...all]/route.ts` — better-auth handler
- Custom REST: `register`, `forgot-password`, `reset-password`, `validate-reset-token` (used by mobile and legacy flows)
**Session cookies**: `better-auth.session_token` or `__Secure-better-auth.session_token` in production.
## Mobile API contract
The Expo app (`beenvoice-app`) 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}`).
3. Requires `trustedOrigins` and matching `BETTER_AUTH_URL` for the host the device can reach.
Ensure `src/lib/auth.ts` keeps the `expo()` plugin enabled.
## MCP (machine clients)
`POST /api/mcp` — JSON-RPC 2.0, protocol `2025-11-25`.
- **Auth**: API key only (`Authorization: Bearer bv_…` or `x-api-key`). Session cookies rejected.
- **Tools**: ~50 tools mirroring tRPC (invoices, clients, time clock, expenses, etc.)
- Implemented in `src/app/api/mcp/route.ts`; delegates to `createCaller(createContext)`.
API keys: format `bv_<base64url>`; stored as SHA-256 hash (`src/server/api/api-keys.ts`).
## Environment variables
Validated in `src/env.js`. See `.env.example`.
| Variable | Required | Notes |
|----------|----------|-------|
| `DATABASE_URL` | yes | PostgreSQL connection string |
| `AUTH_SECRET` | prod | `openssl rand -base64 32` |
| `BETTER_AUTH_URL` | yes | Public URL of API (no trailing path) |
| `NEXT_PUBLIC_APP_URL` | yes | Browser-facing URL |
| `DB_DISABLE_SSL` | local | `true` for Docker dev DB |
| `RESEND_API_KEY`, `RESEND_DOMAIN` | optional | Email; blank disables send |
| `AUTHENTIK_*` | optional | OIDC SSO |
| `DISABLE_SIGNUPS` | optional | `true` blocks registration; use string `true`/`false` (parsed in `src/env.js`) |
| `CRON_SECRET` | cron route | Protects `/api/cron/generate-recurring` |
| `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults |
## Docker
| 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}` |
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.
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>`.
## Scripts
```bash
bun run dev # next dev --turbo
bun run build # production build
bun run db:push # push schema (dev)
bun run db:migrate # run migrations
bun run db:studio # Drizzle Studio
bun run check # eslint + tsc
```
## Public / unauthenticated surfaces
- `invoices.getByPublicToken` (tRPC publicProcedure)
- `/i/[token]` page and `/api/i/[token]/pdf`
- Auth REST endpoints for register/reset
## Related docs
- [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`
+137
View File
@@ -0,0 +1,137 @@
# Coolify deployment — beenvoice + Garage
beenvoice stores receipt files in S3-compatible storage when `S3_BUCKET`, `S3_ACCESS_KEY`, and `S3_SECRET_KEY` are set. [Garage](https://garagehq.deuxfleurs.fr/) is the default on self-hosted Coolify (~50100 MB RAM vs MinIO's ~500 MB+).
## Why `getaddrinfo ENOTFOUND garage` happens
Docker DNS resolves service names **only inside the same Docker network**.
| Setup | Does `http://garage:3900` work? |
|-------|--------------------------------|
| Single Compose stack (app + garage together) | Yes — Compose service name `garage` |
| beenvoice **Application** + Garage **separate Compose** | **No** — each resource has its own network by default |
| Application + Garage with shared destination network + correct hostname | Yes — hostname is usually **`garage-<resource-uuid>`**, not bare `garage` |
| Application + Garage via **public domain** (`SERVICE_URL_GARAGE_3900`) | Yes — no Docker DNS needed |
Setting `S3_ENDPOINT=http://garage:3900` on a standalone beenvoice Application fails because the app container is not on the Garage stack's network. Node returns `ENOTFOUND garage`.
Also avoid `http://localhost:3900` inside the app container — that points at the app itself, not Garage.
---
## Quick fix — keep beenvoice as Application + separate Garage compose
Use this if you are **not** migrating to a single Compose stack today.
### Path A — public Garage URL (recommended, works without shared Docker network)
This is the most reliable fix when beenvoice is a Coolify **Application** (Dockerfile) and Garage is a separate Compose resource.
1. **Update the Garage stack** to the latest `docker-compose.coolify-garage.yml` from this repo (includes `SERVICE_FQDN_GARAGE_3900`) and **redeploy** the Garage resource.
2. In the **Garage Compose resource** → assign a domain for **port 3900** (e.g. `s3.yourdomain.com`). Coolify generates TLS via Traefik/Caddy.
3. Open the Garage resource **Environment** tab and copy **`SERVICE_URL_GARAGE_3900`** (e.g. `https://s3.yourdomain.com`).
4. On the **beenvoice Application** → Environment:
```env
S3_ENDPOINT=https://s3.yourdomain.com
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=<same as GARAGE_DEFAULT_ACCESS_KEY / S3_ACCESS_KEY on Garage stack>
S3_SECRET_KEY=<same as GARAGE_DEFAULT_SECRET_KEY / S3_SECRET_KEY on Garage stack>
S3_REGION=garage
```
5. **Redeploy beenvoice** (restart is not enough after env changes on some Coolify versions — trigger a full redeploy).
`S3_FORCE_PATH_STYLE` defaults to on when `S3_ENDPOINT` is set (required for Garage behind a reverse proxy). Only set `S3_FORCE_PATH_STYLE=false` if you use AWS S3 with virtual-hosted-style buckets.
### Path B — internal Docker DNS (same destination, no public Garage domain)
Use when you want S3 API traffic to stay on the Docker network.
1. Put beenvoice Application and Garage Compose in the **same Coolify project** and **same destination** (server/network).
2. **Garage Compose resource****Advanced** → enable **Connect to Predefined Network****redeploy Garage**.
3. **beenvoice Application****Advanced** → enable **Connect to Predefined Network** (same destination) → **redeploy beenvoice**.
4. Find the Garage resource **UUID** (in the Coolify URL, e.g. `.../service/abc123def456`, or env `COOLIFY_RESOURCE_UUID` on the Garage container).
5. Set on beenvoice Application:
```env
S3_ENDPOINT=http://garage-<GARAGE_RESOURCE_UUID>:3900
```
Example: resource UUID `k8w2o0g4s0g8``S3_ENDPOINT=http://garage-k8w2o0g4s0g8:3900`.
**Do not use bare `garage`** unless you verified it resolves from inside the beenvoice container (recent Coolify versions may also register the short service name when both sides use Connect to Predefined Network — if `wget http://garage:3900` fails, use the `garage-<uuid>` form or Path A).
6. Match credentials and bucket:
```env
S3_BUCKET=beenvoice-receipts
S3_ACCESS_KEY=<S3_ACCESS_KEY on Garage stack>
S3_SECRET_KEY=<S3_SECRET_KEY on Garage stack>
S3_REGION=garage
```
---
## Recommended long-term — one Compose stack
Deploy **[`docker-compose.coolify.yml`](../docker-compose.coolify.yml)** as **one** Coolify **Docker Compose** resource (app + Postgres + Garage). This is the lowest-friction production layout on Coolify.
1. Coolify → **New Resource****Docker Compose**
2. Point at this repo; compose file: **`docker-compose.coolify.yml`**
3. Set env vars from [`.env.example`](../.env.example): `AUTH_SECRET`, `POSTGRES_PASSWORD`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, etc.
4. Assign a domain to the **`app`** service (Coolify fills `SERVICE_URL_APP` / `BETTER_AUTH_URL` automatically).
5. **Do not** override `S3_ENDPOINT` — the compose file sets `S3_ENDPOINT=http://garage:3900` on the shared network.
6. Redeploy.
Alternative: [`docker-compose.yml`](../docker-compose.yml) works the same way; `docker-compose.coolify.yml` adds Coolify magic vars (`SERVICE_FQDN_APP`) and omits host port bindings for db/Garage.
### Migrating from Application + external Postgres + Garage (or legacy MinIO)
| Current | Action |
|---------|--------|
| beenvoice Application | Remove after Compose stack is live |
| Separate Postgres | Dump/restore into stack `db`, or keep external DB and delete the `db` service from the compose file |
| Garage / MinIO compose | Remove after data migrated (rclone) or re-point receipts (new bucket) |
| Env vars | Move `AUTH_SECRET`, Resend, Authentik, etc. to the Compose resource env |
**Migrating from MinIO:** Garage uses port **3900** (not 9000) and Garage-format access keys (`GK…`). Update `S3_ENDPOINT`, `S3_REGION=garage`, and credentials. Receipt blobs in the old MinIO volume are not auto-migrated.
---
## Compose file reference
| File | Purpose |
|------|---------|
| [`docker-compose.coolify.yml`](../docker-compose.coolify.yml) | **Recommended** — full stack for one Coolify Compose resource |
| [`docker-compose.yml`](../docker-compose.yml) | Full stack (local/VPS); also valid on Coolify |
| [`docker-compose.coolify-garage.yml`](../docker-compose.coolify-garage.yml) | Garage only; pair with beenvoice Application (Path A or B above) |
Do **not** add `networks: coolify: external: true` unless you know the exact external network name on your server. Coolify v4 uses **destinations**; network names are often UUID-based. Prefer the UI **Connect to Predefined Network** toggle over hard-coding `coolify` in compose.
---
## Checklist (Application + separate Garage)
- [ ] Garage stack redeployed with current `docker-compose.coolify-garage.yml`
- [ ] **Path A:** domain on port 3900 + `S3_ENDPOINT` = `SERVICE_URL_GARAGE_3900`
**or Path B:** Connect to Predefined Network on **both** resources + `S3_ENDPOINT=http://garage-<uuid>:3900`
- [ ] `S3_ENDPOINT` is **not** `http://garage:3900`, **not** `localhost`
- [ ] `S3_ACCESS_KEY` / `S3_SECRET_KEY` match the Garage stack env
- [ ] `S3_BUCKET` exists (Garage `--default-bucket` creates `beenvoice-receipts` on first start)
- [ ] Redeployed beenvoice after env or network changes
## Verify from the beenvoice container
```bash
# Shell into beenvoice app container on the Coolify server
docker exec -it <beenvoice-container> sh
# Path A — public URL (403/404 on root is fine — confirms DNS + TLS)
wget -qO- "https://s3.yourdomain.com" || curl -sf "https://s3.yourdomain.com"
# Path B — internal host from S3_ENDPOINT
wget -qO- "http://garage-<uuid>:3900" || curl -sf "http://garage-<uuid>:3900"
```
If this fails with "bad address" or timeout, fix networking / `S3_ENDPOINT` before debugging app code. On first S3 use, the app logs a hint if DNS fails or if `S3_ENDPOINT` still uses bare `garage` in production.
+36
View File
@@ -0,0 +1,36 @@
# beenvoice-web documentation
**Repository:** [git.soconnor.dev/soconnor/beenvoice-web](https://git.soconnor.dev/soconnor/beenvoice-web)
## Core
| Document | Description |
|----------|-------------|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract |
| [../README.md](../README.md) | Install, scripts, deployment |
| [COOLIFY.md](./COOLIFY.md) | Coolify + Garage networking (`ENOTFOUND garage`) |
## UI & product guides
| Document | Description |
|----------|-------------|
| [forms-guide.md](./forms-guide.md) | Form patterns |
| [UI_UNIFORMITY_GUIDE.md](./UI_UNIFORMITY_GUIDE.md) | Visual consistency |
| [breadcrumbs-guide.md](./breadcrumbs-guide.md) | Navigation breadcrumbs |
| [data-table-responsive-guide.md](./data-table-responsive-guide.md) | Responsive tables |
| [data-table-improvements.md](./data-table-improvements.md) | Table enhancements |
| [RESPONSIVE_TABLE_EXAMPLES.md](./RESPONSIVE_TABLE_EXAMPLES.md) | Table examples |
| [email-features.md](./email-features.md) | Email composer / delivery |
## Mobile
| 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 |
## Workspace
| Document | Description |
|----------|-------------|
| [../../README.md](../../README.md) | Meta repo layout, full-stack quick start |
+138
View File
@@ -0,0 +1,138 @@
# Responsive Table Examples
This document shows how tables adapt across different screen sizes in the beenvoice application.
## Mobile View (< 640px)
### Invoices Table
- **Visible**: Invoice number, client name, amount, status, actions
- **Hidden**: Issue date, due date (shown on detail view)
- **Features**: Compact spacing, smaller buttons, simplified pagination
### Clients Table
- **Visible**: Name with email, actions
- **Hidden**: Phone, address, created date
- **Icon**: Hidden on mobile to save space
### Businesses Table
- **Visible**: Name with email, actions
- **Hidden**: Phone, address, tax ID, website
- **Icon**: Hidden on mobile to save space
## Tablet View (640px - 1024px)
### Invoices Table
- **Added**: Issue date column
- **Still Hidden**: Due date (less critical than issue date)
- **Features**: Search bar expands, column visibility toggle appears
### Clients Table
- **Added**: Phone column, client icon
- **Still Hidden**: Address, created date
- **Features**: Better spacing, full search functionality
### Businesses Table
- **Added**: Phone column, business icon
- **Still Hidden**: Address, tax ID
- **Features**: Website links become visible
## Desktop View (> 1024px)
### All Tables
- **Full Features**: All columns visible
- **Enhanced**:
- Full pagination controls with page size selector
- Column visibility toggle
- Advanced filters
- Comfortable spacing
- All metadata visible
## Code Examples
### Responsive Column Definition
```tsx
// Hide on mobile, show on tablet and up
{
accessorKey: "phone",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Phone" />
),
cell: ({ row }) => (
<span className="hidden md:inline">{row.original.phone || "—"}</span>
),
}
// Hide on mobile and tablet, show on desktop
{
id: "address",
header: "Address",
cell: ({ row }) => (
<span className="hidden lg:inline">{formatAddress(row.original)}</span>
),
}
```
### Responsive Cell Content
```tsx
// Icon hidden on mobile
<div className="flex items-center gap-3">
<div className="hidden rounded-lg bg-status-info-muted p-2 sm:flex">
<UserPlus className="h-4 w-4 text-status-info" />
</div>
<div className="min-w-0">
<p className="truncate font-medium">{client.name}</p>
<p className="truncate text-sm text-muted-foreground">
{client.email || "—"}
</p>
</div>
</div>
```
### Responsive Actions
```tsx
// Compact action buttons that work on all screen sizes
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
```
## Filter Bar Behavior
### Mobile
- Search input takes full width
- Filter dropdowns stack vertically
- Column visibility hidden
- Clear filters button visible when filters active
### Tablet+
- Search input limited to max-width
- Filter dropdowns in horizontal row
- Column visibility toggle appears
- All controls in single row
## Pagination Behavior
### Mobile
- Simplified page indicator (1/5 format)
- Compact button spacing
- Page size selector with smaller text
### Desktop
- Full "Page 1 of 5" text
- Comfortable button spacing
- First/Last page buttons visible
- Entries count with detailed information
## Best Practices
1. **Priority Content**: Always show the most important data on mobile
2. **Progressive Enhancement**: Add columns as screen size increases
3. **Touch Targets**: Maintain 44px minimum touch targets on mobile
4. **Text Truncation**: Use `truncate` class for long text in narrow columns
5. **Icon Usage**: Hide decorative icons on mobile, keep functional ones
6. **Testing**: Always test at 375px (iPhone SE), 768px (iPad), and 1440px (Desktop)
+324
View File
@@ -0,0 +1,324 @@
# UI Uniformity Guide for beenvoice
## Overview
This guide documents the unified component system implemented across the beenvoice application to ensure consistent UI/UX patterns. The system follows a hierarchical approach where:
1. **CSS Variables** (in `globals.css`) define the design tokens
2. **UI Components** (in `components/ui`) consume these variables
3. **Pages** use components with minimal additional styling
## Design System Principles
### 1. Variable-Based Theming
All colors, spacing, and other design tokens are defined as CSS variables in `globals.css`:
- Brand colors: `--brand-primary`, `--brand-secondary`
- Status colors: `--status-success`, `--status-warning`, `--status-error`, `--status-info`
- Semantic colors: `--background`, `--foreground`, `--muted`, etc.
### 2. Component Composition
Complex UI patterns are built from smaller, reusable components rather than duplicating code.
### 3. Minimal Page-Level Styling
Pages should primarily compose pre-built components and avoid custom Tailwind classes where possible.
## Core Unified Components
### Page Layout Components
#### `PageContent`
Wraps page content with consistent spacing:
```tsx
<PageContent spacing="default">
{/* Page sections */}
</PageContent>
```
#### `PageSection`
Groups related content with optional title and actions:
```tsx
<PageSection
title="Section Title"
description="Optional description"
actions={<Button>Action</Button>}
>
{/* Section content */}
</PageSection>
```
#### `PageGrid`
Responsive grid layout with preset column options:
```tsx
<PageGrid columns={3} gap="default">
{/* Grid items */}
</PageGrid>
```
### Data Display Components
#### `DataTable`
Unified table component using @tanstack/react-table with floating card design:
```tsx
import { ColumnDef } from "@tanstack/react-table";
import { DataTable, DataTableColumnHeader } from "~/components/ui/data-table";
import { PageSection } from "~/components/ui/page-layout";
const columns: ColumnDef<DataType>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => {
const name = row.getValue("name") as string;
return <div className="font-medium">{name}</div>;
}
},
{
id: "actions",
cell: ({ row }) => {
const item = row.original;
return (
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Edit className="h-3.5 w-3.5" />
</Button>
);
}
}
];
const filterableColumns = [
{
id: "status",
title: "Status",
options: [
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" }
]
}
];
// Wrap in PageSection for title/description
<PageSection
title="Table Title"
description="Optional description"
>
<DataTable
columns={columns}
data={data}
searchPlaceholder="Search by name..."
filterableColumns={filterableColumns}
/>
</PageSection>
```
Features:
- **Floating Card Design**: Three separate cards for filter bar, table content, and pagination
- **Filter Bar Card**: Minimal padding (p-3) with global search and column filters
- **Table Content Card**: Clean borders with overflow handling
- **Pagination Card**: Compact controls with page size selector
- **Responsive Design**: Mobile-optimized with hidden columns on smaller screens
- **Tight Appearance**: Compact spacing with smaller action buttons
- **Sorting**: Visual indicators with proper arrow directions
- **Column Visibility**: Toggle columns (hidden on mobile)
- **Dark Mode**: Consistent styling across light/dark themes
- **Loading States**: DataTableSkeleton component with matching card structure
#### `StatsCard`
Displays statistics with consistent styling:
```tsx
<StatsCard
title="Total Revenue"
value="$10,000"
icon={DollarSign}
description="From 50 invoices"
variant="success"
/>
```
#### `QuickActionCard`
Interactive cards for navigation or actions:
```tsx
<QuickActionCard
title="Create Invoice"
description="Start a new invoice"
icon={Plus}
variant="success"
>
<Link href="/invoices/new">
<div className="h-full w-full" />
</Link>
</QuickActionCard>
```
### Feedback Components
#### `EmptyState`
Consistent empty state displays:
```tsx
<EmptyState
icon={<FileText className="h-8 w-8" />}
title="No invoices yet"
description="Create your first invoice to get started"
action={<Button>Create Invoice</Button>}
/>
```
## Component Variants
### Color Variants
Most components support these variants:
- `default` - Uses default theme colors
- `success` - Green color scheme for positive states
- `warning` - Orange/amber for warnings
- `error` - Red for errors or destructive actions
- `info` - Blue for informational content
### Size Variants
- `sm` - Small size
- `default` - Normal size
- `lg` - Large size
## Usage Examples
### Standard Page Structure
```tsx
export default function ExamplePage() {
return (
<PageContent>
<PageHeader
title="Page Title"
description="Page description"
variant="gradient"
>
<Button variant="brand">
Primary Action
</Button>
</PageHeader>
<PageSection>
<PageGrid columns={4}>
<StatsCard {...statsProps} />
</PageGrid>
</PageSection>
<PageSection
title="Data Table Title"
description="Table description"
>
<DataTable {...tableProps} />
</PageSection>
</PageContent>
);
}
```
### Consistent Button Usage
```tsx
// Primary actions
<Button variant="brand">Create New</Button>
// Secondary actions
<Button variant="outline">Cancel</Button>
// Destructive actions
<Button variant="destructive">Delete</Button>
// Icon-only actions
<Button variant="ghost" size="icon">
<Edit className="h-4 w-4" />
</Button>
```
## Styling Guidelines
### Do's
- ✅ Use predefined color variables from globals.css
- ✅ Compose existing UI components
- ✅ Use semantic variant names (success, error, etc.)
- ✅ Follow the established spacing patterns
- ✅ Use the PageLayout components for structure
### Don'ts
- ❌ Add custom colors directly in components
- ❌ Create one-off table or card implementations
- ❌ Override component styles with important flags
- ❌ Use arbitrary spacing values
- ❌ Mix different UI patterns on the same page
## Migration Checklist
When updating a page to use the unified system:
1. Replace custom tables with `DataTable` using @tanstack/react-table ColumnDef
2. Replace statistics displays with `StatsCard`
3. Replace action cards with `QuickActionCard`
4. Wrap content in `PageContent` and `PageSection`
5. Use `PageGrid` for responsive layouts
6. Replace custom empty states with `EmptyState`
7. Update buttons to use the `brand` variant for primary actions
8. Remove page-specific color classes
9. Use `DataTableColumnHeader` for sortable column headers
10. Use `DataTableSkeleton` for loading states
## Color System Reference
### Brand Colors
- Primary: Green (`#16a34a` / `oklch(0.646 0.222 164.25)`)
- Secondary: Teal/cyan shades
- Gradients: Use `bg-brand-gradient` class
### Status Colors
- Success: Green shades
- Warning: Amber/orange shades
- Error: Red shades
- Info: Blue shades
### Semantic Colors
- Background: White/dark gray
- Foreground: Black/white text
- Muted: Gray shades for secondary content
- Border: Light gray borders
## Component Documentation
For detailed component APIs and props, refer to:
- `/src/components/ui/data-table.tsx` - TanStack Table-based data table with sorting, filtering, and pagination
- `/src/components/ui/stats-card.tsx` - Statistics display cards
- `/src/components/ui/quick-action-card.tsx` - Interactive action cards
- `/src/components/ui/page-layout.tsx` - Page structure components
### DataTable Props
- `columns`: ColumnDef array from @tanstack/react-table
- `data`: Array of data to display
- `searchPlaceholder?`: Placeholder text for search input
- `showColumnVisibility?`: Show/hide column visibility toggle (default: true)
- `showPagination?`: Show/hide pagination controls (default: true)
- `showSearch?`: Show/hide search input (default: true)
- `pageSize?`: Number of items per page (default: 10)
- `filterableColumns?`: Array of column filters with options
Note: `title` and `description` should be provided via the wrapping `PageSection` component for consistent spacing and typography.
### Responsive Table Guidelines
- Use `hidden sm:flex` classes for icons in table cells
- Use `hidden md:inline` for less important columns on mobile
- Use `min-w-0` and `truncate` for text that might overflow
- Keep action buttons small with `h-8 w-8 p-0` sizing
- Test tables at all breakpoints (mobile, tablet, desktop)
## Future Considerations
1. **Form Components**: Create unified form field components
2. **Modal Patterns**: Standardize modal and dialog usage
3. **Loading States**: Create consistent skeleton loaders
4. **Animation**: Define standard transition patterns
5. **Icons**: Establish icon usage guidelines
## Maintenance
To maintain UI consistency:
1. Always check for existing components before creating new ones
2. Update this guide when adding new unified components
3. Review PRs for adherence to these patterns
4. Refactor pages that deviate from the system
+198
View File
@@ -0,0 +1,198 @@
# Dynamic Breadcrumbs Guide
## Overview
The breadcrumb system in beenvoice automatically generates navigation trails based on the current URL path. It features intelligent pluralization, proper capitalization, and dynamic resource name fetching.
## Key Features
### 1. Automatic Pluralization
The breadcrumb system intelligently handles singular and plural forms:
- **List pages** (e.g., `/dashboard/businesses`) → "Businesses"
- **Detail pages** (e.g., `/dashboard/businesses/[id]`) → "Business"
- **New pages** (e.g., `/dashboard/businesses/new`) → "Business" (singular context)
### 2. Smart Capitalization
All route segments are automatically capitalized:
- `businesses` → "Businesses"
- `clients` → "Clients"
- `invoices` → "Invoices"
### 3. Dynamic Resource Names
Instead of showing UUIDs, breadcrumbs fetch and display actual resource names:
- `/dashboard/clients/123e4567-e89b-12d3-a456-426614174000` → "Dashboard / Clients / John Doe"
- `/dashboard/invoices/987fcdeb-51a2-43f1-b321-123456789abc` → "Dashboard / Invoices / INV-2024-001"
### 4. Context-Aware Labels
Special pages are handled intelligently:
- **Edit pages**: Show the resource name instead of "Edit" as the last breadcrumb
- **New pages**: Show "New" as the last breadcrumb
- **Import/Export pages**: Show appropriate action labels
## Implementation Details
### Pluralization Rules
The system uses a comprehensive pluralization utility (`src/lib/pluralize.ts`) that handles:
```typescript
// Common business terms
business businesses
client clients
invoice invoices
category categories
company companies
// General rules
- Words ending in 's', 'ss', 'sh', 'ch', 'x', 'z' add 'es'
- Words ending in consonant + 'y' change to 'ies'
- Words ending in 'f' or 'fe' change to 'ves'
- Default add 's'
```
### Resource Fetching
The breadcrumbs automatically detect resource IDs and fetch the appropriate data:
```typescript
// Detects UUID patterns in the URL
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
// Fetches data based on resource type
- Clients: Shows client name
- Invoices: Shows invoice number or formatted date
- Businesses: Shows business name
```
### Loading States
While fetching resource data, breadcrumbs show loading skeletons:
```tsx
<Skeleton className="inline-block h-5 w-24 align-middle" />
```
## Usage Examples
### Basic List Page
**URL**: `/dashboard/clients`
**Breadcrumbs**: Dashboard / Clients
### Resource Detail Page
**URL**: `/dashboard/clients/550e8400-e29b-41d4-a716-446655440000`
**Breadcrumbs**: Dashboard / Clients / Jane Smith
### Resource Edit Page
**URL**: `/dashboard/businesses/550e8400-e29b-41d4-a716-446655440000/edit`
**Breadcrumbs**: Dashboard / Businesses / Acme Corp
*(Note: "Edit" is hidden when showing the resource name)*
### New Resource Page
**URL**: `/dashboard/invoices/new`
**Breadcrumbs**: Dashboard / Invoices / New
### Nested Resources
**URL**: `/dashboard/clients/550e8400-e29b-41d4-a716-446655440000/invoices`
**Breadcrumbs**: Dashboard / Clients / John Doe / Invoices
## Customization
### Adding New Resource Types
To add a new resource type, update the pluralization rules:
```typescript
// In src/lib/pluralize.ts
const PLURALIZATION_RULES = {
// ... existing rules
product: { singular: "Product", plural: "Products" },
service: { singular: "Service", plural: "Services" },
};
```
### Custom Resource Labels
For resources that need custom display logic, add to the breadcrumb component:
```typescript
// For invoices, show invoice number instead of ID
if (prevSegment === "invoices") {
label = invoice.invoiceNumber || format(new Date(invoice.issueDate), "MMM dd, yyyy");
}
```
### Special Segments
Add new special segments to the `SPECIAL_SEGMENTS` object:
```typescript
const SPECIAL_SEGMENTS = {
new: "New",
edit: "Edit",
import: "Import",
export: "Export",
duplicate: "Duplicate",
archive: "Archive",
};
```
## Best Practices
1. **Consistent Naming**: Use consistent URL patterns across your app
- List pages: `/dashboard/[resource]`
- Detail pages: `/dashboard/[resource]/[id]`
- Actions: `/dashboard/[resource]/[id]/[action]`
2. **Resource Fetching**: Only fetch data when needed
- Check resource type before enabling queries
- Use proper loading states
3. **Error Handling**: Handle cases where resources don't exist
- Show fallback text or maintain UUID display
- Don't break the breadcrumb trail
4. **Performance**: Breadcrumb queries are lightweight
- Only fetch minimal data (id, name)
- Use React Query caching effectively
## API Integration
The breadcrumb component integrates with tRPC routers:
```typescript
// Each resource router should have a getById method
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
// Return resource with at least id and name/title
})
```
## Accessibility
- Breadcrumbs use semantic HTML with proper ARIA labels
- Each segment is a link except the current page
- Proper keyboard navigation support
- Screen reader friendly with role="navigation"
## Responsive Design
- Breadcrumbs wrap on smaller screens
- Font sizes adjust: `text-sm sm:text-base`
- Separators scale appropriately
- Loading skeletons match text size
## Migration from Static Breadcrumbs
If migrating from hardcoded breadcrumbs:
1. Remove static breadcrumb definitions
2. Ensure URLs follow consistent patterns
3. Add getById methods to resource routers
4. Update imports to use `DashboardBreadcrumbs`
The dynamic system will automatically generate appropriate breadcrumbs based on the URL structure.
+154
View File
@@ -0,0 +1,154 @@
# Data Table Improvements Summary
## Overview
The data table component has been significantly improved to address padding, scaling, and responsiveness issues. The tables now provide a cleaner, more compact appearance while maintaining excellent usability across all device sizes.
## Key Improvements Made
### 1. Tighter, More Consistent Padding
**Before:**
- Inconsistent padding across different table sections
- Excessive vertical padding making tables feel loose
- Cards had default py-6 padding that was too spacious
**After:**
- Table cells: `py-1.5` (mobile) / `py-2` (desktop) - reduced from `py-2.5` / `py-3`
- Table headers: `h-9` (mobile) / `h-10` (desktop) - reduced from `h-10` / `h-12`
- Filter/pagination cards: `py-2` with `px-3` horizontal padding
- Table card: `p-0` to wrap content tightly
### 2. Improved Responsive Column Handling
**Before:**
```tsx
// Cells would hide but headers remained visible
cell: ({ row }) => (
<span className="hidden md:inline">{row.original.phone}</span>
),
```
**After:**
```tsx
// Both header and cell hide together
cell: ({ row }) => row.original.phone || "—",
meta: {
headerClassName: "hidden md:table-cell",
cellClassName: "hidden md:table-cell",
},
```
### 3. Better Small Card Appearance
- Filter card: Compact `py-2` padding with proper horizontal spacing
- Pagination card: Matching `py-2` padding for consistency
- Content aligned properly within smaller card boundaries
- Removed excessive gaps between elements
- Search box now has consistent padding without extra bottom spacing on mobile
### 4. Responsive Font Sizing
- Base text: `text-xs` on mobile, `text-sm` on desktop
- Consistent scaling across all table elements
- Better readability on small screens without wasting space
## Visual Comparison
### Table Density
- **Before**: ~60px per row with excessive padding
- **After**: ~40px per row with comfortable but efficient spacing
### Card Heights
- **Filter Card**: Reduced from ~80px to ~56px
- **Pagination Card**: Reduced from ~72px to ~48px
- **Table Card**: Now wraps content exactly with no extra space
- **Pagination Layout**: Entry count and pagination controls now stay on the same line on mobile
## Implementation Examples
### Responsive Column Definition
```tsx
const columns: ColumnDef<DataType>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => row.original.name,
// Always visible
},
{
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Email" />
),
cell: ({ row }) => row.original.email,
meta: {
// Hidden on mobile, visible on tablets and up
headerClassName: "hidden md:table-cell",
cellClassName: "hidden md:table-cell",
},
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => formatDate(row.getValue("createdAt")),
meta: {
// Only visible on large screens
headerClassName: "hidden lg:table-cell",
cellClassName: "hidden lg:table-cell",
},
},
];
```
### Page Header Actions
Page headers now properly position action buttons to the right on all screen sizes:
```tsx
<PageHeader
title="Invoices"
description="Manage your invoices and track payments"
variant="gradient"
>
<Button asChild variant="brand" size="lg">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-5 w-5" /> New Invoice
</Link>
</Button>
</PageHeader>
```
### Breakpoint Reference
- `sm`: 640px and up
- `md`: 768px and up
- `lg`: 1024px and up
- `xl`: 1280px and up
## Benefits
1. **More Data Visible**: Tighter spacing allows more rows to be visible without scrolling
2. **Professional Appearance**: Clean, compact design suitable for business applications
3. **Better Mobile UX**: Properly hidden columns prevent layout breaking
4. **Consistent Styling**: All table instances now follow the same spacing rules
5. **Performance**: CSS-only solution with no JavaScript overhead
6. **Improved Mobile Layout**: Pagination controls stay inline with entry count on mobile
7. **Consistent Header Actions**: Action buttons properly positioned to the right
## Migration Checklist
- [x] Update column definitions to use `meta` properties
- [x] Remove inline responsive classes from cell content
- [x] Test on actual mobile devices
- [x] Verify touch targets remain accessible (min 44x44px)
- [x] Check that critical data remains visible on small screens
## Best Practices Going Forward
1. **Column Priority**: Always keep the most important 2-3 columns visible on mobile
2. **Content Density**: Use the tighter spacing for data tables, looser spacing for content lists
3. **Responsive Testing**: Test at 320px, 768px, and 1024px minimum
4. **Accessibility**: Ensure interactive elements maintain proper touch targets despite tighter spacing
@@ -0,0 +1,246 @@
# Data Table Responsive Design Guide
## Overview
The data table component has been updated to provide better responsive behavior, consistent padding, and proper scaling across different screen sizes.
## Key Improvements
### 1. Consistent Padding
- Uniform padding across all table elements
- Responsive padding that scales with screen size
- Cards now have consistent spacing (p-3 on mobile, p-4 on desktop)
### 2. Proper Responsive Column Hiding
- Columns now properly hide both headers and cells on smaller screens
- Uses `meta` properties for clean column visibility control
- No more orphaned headers on mobile devices
### 3. Better Scaling
- Font sizes adapt to screen size (text-xs on mobile, text-sm on desktop)
- Button sizes and spacing adjust appropriately
- Pagination controls are optimized for touch devices
## Using Responsive Columns
### Basic Column Definition
```tsx
const columns: ColumnDef<YourDataType>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => row.original.name,
// Always visible on all screen sizes
},
{
accessorKey: "phone",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Phone" />
),
cell: ({ row }) => row.original.phone || "—",
meta: {
// Hidden on mobile, visible on md screens and up
headerClassName: "hidden md:table-cell",
cellClassName: "hidden md:table-cell",
},
},
{
accessorKey: "address",
header: "Address",
cell: ({ row }) => formatAddress(row.original),
meta: {
// Hidden on mobile and tablet, visible on lg screens and up
headerClassName: "hidden lg:table-cell",
cellClassName: "hidden lg:table-cell",
},
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => formatDate(row.getValue("createdAt")),
meta: {
// Only visible on xl screens and up
headerClassName: "hidden xl:table-cell",
cellClassName: "hidden xl:table-cell",
},
},
];
```
### Responsive Breakpoints
- **Always visible**: No meta properties needed
- **md and up** (768px+): `hidden md:table-cell`
- **lg and up** (1024px+): `hidden lg:table-cell`
- **xl and up** (1280px+): `hidden xl:table-cell`
## Complex Cell Content
For cells with complex content that should partially hide on mobile:
```tsx
{
accessorKey: "client",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Client" />
),
cell: ({ row }) => {
const client = row.original;
return (
<div className="flex items-center gap-3">
{/* Icon hidden on mobile, shown on sm screens */}
<div className="bg-status-info-muted hidden rounded-lg p-2 sm:flex">
<UserIcon className="text-status-info h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate font-medium">{client.name}</p>
{/* Secondary info can be hidden on very small screens if needed */}
<p className="text-muted-foreground truncate text-sm">
{client.email || "—"}
</p>
</div>
</div>
);
},
}
```
## Best Practices
### 1. Priority-Based Column Hiding
- Always show the most important columns (e.g., name, status, primary action)
- Hide supplementary information first (e.g., dates, secondary details)
- Consider hiding decorative elements (icons) on mobile while keeping text
### 2. Mobile-First Design
- Ensure at least 2-3 columns are visible on mobile
- Test on actual devices, not just browser dev tools
- Consider the minimum viable information for each row
### 3. Touch-Friendly Actions
- Action buttons should be at least 44x44px on mobile
- Use appropriate spacing between interactive elements
- Consider grouping actions in a dropdown on mobile
### 4. Performance
- The responsive system uses CSS classes, so there's no JavaScript overhead
- Column visibility is handled by Tailwind's responsive utilities
- No re-renders needed when resizing
## Migration Guide
If you have existing data tables, update them as follows:
### Before:
```tsx
{
accessorKey: "phone",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Phone" />
),
cell: ({ row }) => (
<span className="hidden md:inline">{row.original.phone || "—"}</span>
),
}
```
### After:
```tsx
{
accessorKey: "phone",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Phone" />
),
cell: ({ row }) => row.original.phone || "—",
meta: {
headerClassName: "hidden md:table-cell",
cellClassName: "hidden md:table-cell",
},
}
```
## Common Patterns
### Status Columns
Always visible, use color and icons to convey information efficiently:
```tsx
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
}
```
### Date Columns
Often hidden on mobile, show relative dates when space is limited:
```tsx
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("createdAt") as Date;
return (
<>
{/* Full date on larger screens */}
<span className="hidden sm:inline">{formatDate(date)}</span>
{/* Relative date on mobile */}
<span className="sm:hidden">{formatRelativeDate(date)}</span>
</>
);
},
}
```
### Action Columns
Keep actions accessible but space-efficient:
```tsx
{
id: "actions",
cell: ({ row }) => {
const item = row.original;
return (
<div className="flex items-center justify-end gap-1">
{/* Show individual buttons on larger screens */}
<div className="hidden sm:flex sm:gap-1">
<EditButton item={item} />
<DeleteButton item={item} />
</div>
{/* Dropdown menu on mobile */}
<div className="sm:hidden">
<ActionsDropdown item={item} />
</div>
</div>
);
},
}
```
## Testing Checklist
- [ ] Table is readable on 320px wide screens
- [ ] Headers and cells align properly at all breakpoints
- [ ] Touch targets are at least 44x44px on mobile
- [ ] Horizontal scrolling works smoothly when needed
- [ ] Critical information is always visible
- [ ] Loading states work correctly
- [ ] Empty states are responsive
- [ ] Pagination controls are touch-friendly
## Accessibility Notes
- Hidden columns are properly hidden from screen readers
- Table remains navigable with keyboard at all screen sizes
- Sort controls are accessible on mobile
- Focus indicators are visible on all interactive elements
+281
View File
@@ -0,0 +1,281 @@
# Enhanced Email Sending Features
## Overview
The beenvoice application now includes a comprehensive email sending system with preview, rich text editing, and confirmation features. This enhancement provides a professional email experience for sending invoices to clients.
## Features
### 🎨 Rich Text Email Composer
- **Tiptap Editor Integration**: Professional rich text editing with formatting options
- **Text Formatting**: Bold, italic, strikethrough, and color options
- **Text Alignment**: Left, center, and right alignment
- **Lists**: Bullet points and numbered lists
- **Color Picker**: Choose from a variety of text colors
- **Real-time Preview**: See changes as you type
### 👁️ Email Preview
- **Visual Preview**: See exactly how your email will appear to recipients
- **Invoice Summary**: Displays key invoice details (number, date, amount)
- **Attachment Notice**: Shows PDF attachment information
- **Professional Styling**: Clean, branded email template
- **Responsive Design**: Optimized for all screen sizes with proper text wrapping
- **Mobile-First**: Touch-friendly interface with proper spacing
### ✅ Send Confirmation
- **Two-Step Process**: Compose ↔ Preview with Send Action
- **Action-Based Sending**: Send button available from sidebar and floating action bar
- **Status Updates**: Automatic status change from draft to sent
- **Error Handling**: Clear error messages with specific guidance
- **SSR Compatible**: Proper hydration handling for server-side rendering
### 📄 Smart Templates
- **Auto-Generated Content**: Professional email templates with proper paragraph spacing
- **Time-Based Greetings**: Morning, afternoon, or evening greetings
- **Invoice Details**: Automatically includes invoice number, date, and amount
- **Business Branding**: Uses your business name and contact information
- **Immediate Loading**: Content appears instantly in the editor without requiring tab switching
## Components
### EmailComposer
**Location**: `src/components/forms/email-composer.tsx`
A rich text editor component for composing emails with formatting options.
**Props**:
- `subject`: Email subject line
- `onSubjectChange`: Callback for subject changes
- `content`: Email content (HTML)
- `onContentChange`: Callback for content changes
- `fromEmail`: Sender email address
- `toEmail`: Recipient email address
### EmailPreview
**Location**: `src/components/forms/email-preview.tsx`
Displays a visual preview of how the email will appear to recipients.
**Props**:
- `subject`: Email subject line
- `fromEmail`: Sender email address
- `toEmail`: Recipient email address
- `content`: Email content (HTML)
- `invoice`: Invoice data for summary display
### SendEmailDialog
**Location**: `src/components/forms/send-email-dialog.tsx`
Main dialog component that combines composition, preview, and confirmation.
**Props**:
- `invoiceId`: ID of the invoice to send
- `trigger`: React element that opens the dialog
- `invoice`: Invoice data
- `onEmailSent`: Callback when email is successfully sent
### EnhancedSendInvoiceButton
**Location**: `src/components/forms/enhanced-send-invoice-button.tsx`
Enhanced button component that opens the email dialog.
**Props**:
- `invoiceId`: ID of the invoice to send
- `variant`: Button style variant
- `className`: Additional CSS classes
- `showResend`: Whether to show "Resend" text
- `size`: Button size
## API Enhancements
### Enhanced Email Router
**Location**: `src/server/api/routers/email.ts`
The email API has been enhanced to support custom content and HTML emails.
**New Parameters**:
- `customSubject`: Optional custom email subject
- `customContent`: Optional custom email content (HTML)
- `useHtml`: Boolean flag to send HTML email
**Features**:
- HTML email support with plain text fallback
- Custom subject lines
- Rich HTML content
- Automatic PDF attachment
- BCC to business email
- Comprehensive error handling
## Usage Examples
### Basic Usage
```tsx
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
// Replace existing send buttons
<EnhancedSendInvoiceButton
invoiceId={invoice.id}
className="w-full"
showResend={invoice.status === "sent"}
/>
```
### Custom Dialog
```tsx
import { SendEmailDialog } from "~/components/forms/send-email-dialog";
<SendEmailDialog
invoiceId={invoice.id}
invoice={invoiceData}
trigger={<Button>Send Custom Email</Button>}
onEmailSent={() => console.log("Email sent!")}
/>
```
### Standalone Components
```tsx
import { EmailComposer } from "~/components/forms/email-composer";
import { EmailPreview } from "~/components/forms/email-preview";
// Use individual components for custom implementations
<EmailComposer
subject={subject}
onSubjectChange={setSubject}
content={content}
onContentChange={setContent}
fromEmail="you@business.com"
toEmail="client@company.com"
/>
<EmailPreview
subject={subject}
content={content}
fromEmail="you@business.com"
toEmail="client@company.com"
invoice={invoiceData}
/>
```
## Technical Details
### Dependencies
- **@tiptap/react**: Rich text editor framework
- **@tiptap/starter-kit**: Basic editor functionality
- **@tiptap/extension-text-style**: Text styling support
- **@tiptap/extension-color**: Color picker support
- **@tiptap/extension-text-align**: Text alignment options
### Email Templates
The system generates professional HTML email templates with:
- Responsive design
- Brand colors (green theme)
- Invoice summary cards
- Proper typography
- Attachment indicators
- Footer branding
### Error Handling
Comprehensive error handling for:
- Invalid email addresses
- Missing client information
- Resend API issues
- Network connectivity problems
- Domain verification issues
- Rate limiting
## Usage in Application
The enhanced email functionality is integrated throughout the application:
- Invoice view pages with enhanced send buttons
- Full-page email composition interface
- Professional email templates with invoice integration
- Comprehensive preview and confirmation workflow
## Migration Guide
### From Basic Send Button
Replace existing `SendInvoiceButton` components with `EnhancedSendInvoiceButton`:
```tsx
// Before
import { SendInvoiceButton } from "../_components/send-invoice-button";
<SendInvoiceButton invoiceId={invoice.id} />
// After
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
<EnhancedSendInvoiceButton invoiceId={invoice.id} />
```
### API Compatibility
The enhanced email API is backward compatible with existing implementations. New features are opt-in through additional parameters.
## Security Considerations
- **Input Sanitization**: All user input is validated and sanitized
- **Email Validation**: Comprehensive email format validation
- **Rate Limiting**: Built-in protection against spam
- **Domain Verification**: Resend domain verification required
- **Authentication**: All email operations require valid authentication
## Performance
- **SSR Optimization**: Proper server-side rendering with hydration safeguards
- **Efficient Loading**: Content initializes immediately without requiring user interaction
- **Optimized Rendering**: Efficient React component updates with proper state management
- **Caching**: Proper query caching for invoice data
- **Error Boundaries**: Graceful error handling without crashes
- **Responsive Design**: Optimized layouts for all screen sizes with text overflow prevention
## Navigation
### Send Email Page
Access the email interface by clicking "Send Invoice" on any invoice:
- `/dashboard/invoices/[id]/send` - Full-page email composition
- Two-tab interface: Compose ↔ Preview
- Send action available from sidebar and floating action bar
- Fully responsive design with proper text wrapping and overflow handling
- Professional layout with sidebar containing:
- Invoice summary (number, client, date, status)
- Email details (from, to, subject, attachment info)
- Context-aware action buttons
- Auto-filled message with proper HTML formatting and paragraph spacing
- Immediate content loading without requiring tab navigation
## Fixes and Improvements
Recent fixes and enhancements:
- **SSR Compatibility**: Fixed Tiptap hydration issues for reliable server-side rendering
- **Content Loading**: Improved email content initialization for immediate display
- **Responsive Design**: Enhanced text wrapping and overflow handling for all screen sizes
- **UI/UX**: Removed confirmation tab in favor of action-based sending approach
- **Performance**: Optimized state management for faster content loading
## Future Enhancements
Planned improvements include:
- Email templates library
- Scheduling email delivery
- Email tracking and read receipts
- Bulk email sending
- Custom email signatures
- Integration with email marketing tools
## Support
For issues or questions related to the email system:
1. Check the console for error messages
2. Verify Resend API configuration
3. Ensure client email addresses are valid
4. Review domain verification status
5. Check network connectivity
## Changelog
### Version 1.0.0
- Initial release of enhanced email system
- Rich text editor integration
- Email preview functionality
- Send confirmation workflow
- HTML email support
- Professional templates
- Demo page implementation
+279
View File
@@ -0,0 +1,279 @@
# Forms Improvement Guide
## Overview
The business and client creation/editing forms have been significantly improved with better organization, shared components, enhanced validation, and improved user experience.
## Key Improvements
### 1. Shared Components & Utilities
#### Address Form Component (`src/components/ui/address-form.tsx`)
A reusable address form component that handles:
- Country-aware formatting (US ZIP codes, Canadian postal codes)
- State dropdown for US addresses, text input for other countries
- Popular countries listed first in country dropdown
- Automatic field adjustments based on country selection
```tsx
<AddressForm
addressLine1={formData.addressLine1}
addressLine2={formData.addressLine2}
city={formData.city}
state={formData.state}
postalCode={formData.postalCode}
country={formData.country}
onChange={handleInputChange}
errors={errors}
required={false}
/>
```
#### Form Constants & Utilities (`src/lib/form-constants.ts`)
Centralized location for:
- US states list with proper formatting
- All countries with ISO codes
- Popular countries for quick selection
- Format functions for phone, postal codes, tax IDs, and URLs
- Validation utilities and messages
### 2. Enhanced Form Validation
#### Real-time Validation
- Errors clear as soon as user starts typing
- Field-specific validation messages
- Visual feedback with red borders on invalid fields
#### Smart Validation Rules
- Email: Proper email format checking
- Phone: US phone number format validation
- Address: Required fields only if any address field is filled
- URL: Automatic https:// prefix addition
```typescript
// Example validation
if (formData.email && !isValidEmail(formData.email)) {
newErrors.email = VALIDATION_MESSAGES.email;
}
```
### 3. Better Form Organization
#### Card-based Sections
Forms are now organized into logical sections using cards:
- **Basic Information**: Core fields like name, tax ID
- **Contact Information**: Email, phone, website
- **Address**: Complete address form with smart country handling
- **Settings**: Business-specific settings like default business flag
#### Consistent Layout
- Maximum width container for better readability
- Responsive grid layouts that stack on mobile
- Proper spacing between sections
- Clear visual hierarchy
### 4. Improved User Experience
#### Loading States
- Skeleton loader while fetching data in edit mode
- Disabled form fields during submission
- Loading spinner in submit button
#### Unsaved Changes Warning
```typescript
const handleCancel = () => {
if (isDirty) {
const confirmed = window.confirm(
"You have unsaved changes. Are you sure you want to leave?"
);
if (!confirmed) return;
}
router.push("/dashboard/businesses");
};
```
#### Smart Field Formatting
- Phone numbers: Auto-format as (555) 123-4567
- Tax ID: Auto-format as 12-3456789
- Postal codes: Format based on country (US vs Canadian)
- Website URLs: Auto-add https:// if missing
### 5. Responsive Design
#### Mobile Optimizations
- Form sections stack vertically on small screens
- Touch-friendly input sizes
- Proper button positioning
- Readable font sizes
#### Desktop Enhancements
- Two-column layouts for related fields
- Optimal reading width
- Side-by-side form actions
### 6. Code Reusability
#### Shared Between Business & Client Forms
- Address form component
- Validation logic
- Format functions
- Constants (states, countries)
- Error handling patterns
#### TypeScript Interfaces
```typescript
interface FormData {
name: string;
email: string;
phone: string;
// ... other fields
}
interface FormErrors {
name?: string;
email?: string;
// ... validation errors
}
```
## Usage Examples
### Basic Form Implementation
```tsx
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const [formData, setFormData] = useState<FormData>(initialFormData);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDirty, setIsDirty] = useState(false);
// Handle input changes
const handleInputChange = (field: string, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
setIsDirty(true);
// Clear error when user types
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
// Validate and submit
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
toast.error("Please correct the errors in the form");
return;
}
// Submit logic...
};
}
```
### Field with Icon and Validation
```tsx
<div className="space-y-2">
<Label htmlFor="email">
Email
<span className="text-muted-foreground ml-1 text-xs">(Optional)</span>
</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
placeholder={PLACEHOLDERS.email}
className={`pl-10 ${errors.email ? "border-destructive" : ""}`}
disabled={isSubmitting}
/>
</div>
{errors.email && (
<p className="text-sm text-destructive">{errors.email}</p>
)}
</div>
```
## Best Practices
### 1. Form State Management
- Use controlled components for all inputs
- Track dirty state for unsaved changes warnings
- Clear errors when user corrects them
- Disable form during submission
### 2. Validation Strategy
- Validate on submit, not on blur (less annoying)
- Clear errors immediately when user starts fixing them
- Show field-level errors below each input
- Use consistent error message format
### 3. Accessibility
- Proper label associations with htmlFor
- Required field indicators
- Error messages linked to fields
- Keyboard navigation support
- Focus management
### 4. Performance
- Memoize expensive computations
- Use debouncing for format functions if needed
- Lazy load country lists
- Optimize re-renders with proper state management
## Migration Guide
### From Old Forms
1. Replace inline state/country arrays with imported constants
2. Use `AddressForm` component instead of individual address fields
3. Apply format functions from `form-constants.ts`
4. Update validation to use shared utilities
5. Wrap sections in Card components
6. Add loading and dirty state tracking
### Example Migration
```tsx
// Before
const US_STATES = [
{ value: "AL", label: "Alabama" },
// ... duplicated in each form
];
// After
import { US_STATES, formatPhoneNumber } from "~/lib/form-constants";
import { AddressForm } from "~/components/ui/address-form";
```
## Future Enhancements
### Planned Improvements
1. **Field-level permissions**: Disable fields based on user role
2. **Auto-save**: Save draft as user types
3. **Multi-step forms**: Break long forms into steps
4. **Conditional fields**: Show/hide fields based on other values
5. **Bulk operations**: Create multiple records at once
6. **Import from templates**: Pre-fill common business types
### Extensibility
The form system is designed to be easily extended:
- Add new format functions to `form-constants.ts`
- Create additional shared form components
- Extend validation rules as needed
- Add new field types with consistent patterns
## Troubleshooting
### Common Issues
1. **Validation not working**: Ensure field names match FormErrors interface
2. **Format function not applying**: Check that onChange uses the format function
3. **Country dropdown not searching**: Verify SearchableSelect has search enabled
4. **Address validation failing**: Check if country field affects validation rules
### Debug Tips
- Use React DevTools to inspect form state
- Check console for validation errors
- Verify API responses match expected format
- Test with different country selections
+23
View File
@@ -0,0 +1,23 @@
import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
// Load .env.local if it exists
dotenv.config({ path: ".env.local" });
// Load .env if it exists (fallback)
dotenv.config({ path: ".env" });
// Use a relative import; path alias "~" may not resolve in CLI context
// import { env } from "./src/env.js";
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
}
export default {
schema: "./src/server/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
},
tablesFilter: ["beenvoice_*"],
} satisfies Config;
+166
View File
@@ -0,0 +1,166 @@
CREATE TABLE "beenvoice_account" (
"id" text PRIMARY KEY NOT NULL,
"userId" varchar(255) NOT NULL,
"accountId" varchar(255) NOT NULL,
"providerId" varchar(255) NOT NULL,
"accessToken" text,
"refreshToken" text,
"accessTokenExpiresAt" timestamp,
"refreshTokenExpiresAt" timestamp,
"scope" varchar(255),
"idToken" text,
"password" text,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "beenvoice_business" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"nickname" varchar(255),
"email" varchar(255),
"phone" varchar(50),
"addressLine1" varchar(255),
"addressLine2" varchar(255),
"city" varchar(100),
"state" varchar(50),
"postalCode" varchar(20),
"country" varchar(100),
"website" varchar(255),
"taxId" varchar(100),
"logoUrl" varchar(500),
"isDefault" boolean DEFAULT false,
"resendApiKey" varchar(255),
"resendDomain" varchar(255),
"emailFromName" varchar(255),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_client" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"email" varchar(255),
"phone" varchar(50),
"addressLine1" varchar(255),
"addressLine2" varchar(255),
"city" varchar(100),
"state" varchar(50),
"postalCode" varchar(20),
"country" varchar(100),
"defaultHourlyRate" real,
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_invoice_item" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"invoiceId" varchar(255) NOT NULL,
"date" timestamp NOT NULL,
"description" varchar(500) NOT NULL,
"hours" real NOT NULL,
"rate" real NOT NULL,
"amount" real NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "beenvoice_invoice" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"invoiceNumber" varchar(100) NOT NULL,
"businessId" varchar(255),
"clientId" varchar(255) NOT NULL,
"issueDate" timestamp NOT NULL,
"dueDate" timestamp NOT NULL,
"status" varchar(50) DEFAULT 'draft' NOT NULL,
"totalAmount" real DEFAULT 0 NOT NULL,
"taxRate" real DEFAULT 0 NOT NULL,
"notes" varchar(1000),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_session" (
"id" text PRIMARY KEY NOT NULL,
"userId" varchar(255) NOT NULL,
"token" varchar(255) NOT NULL,
"expiresAt" timestamp NOT NULL,
"ipAddress" text,
"userAgent" text,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "beenvoice_session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "beenvoice_sso_provider" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"providerId" varchar(255) NOT NULL,
"userId" varchar(255) NOT NULL,
"redirectURI" varchar(255) DEFAULT '' NOT NULL,
"oidcConfig" text,
"samlConfig" text,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "beenvoice_sso_provider_providerId_unique" UNIQUE("providerId")
);
--> statement-breakpoint
CREATE TABLE "beenvoice_user" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"email" varchar(255) NOT NULL,
"emailVerified" boolean DEFAULT false NOT NULL,
"image" varchar(255),
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"password" varchar(255),
"resetToken" varchar(255),
"resetTokenExpiry" timestamp,
"prefersReducedMotion" boolean DEFAULT false NOT NULL,
"animationSpeedMultiplier" real DEFAULT 1 NOT NULL,
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
"customColor" varchar(50),
"theme" varchar(20) DEFAULT 'system' NOT NULL,
CONSTRAINT "beenvoice_user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "beenvoice_verification_token" (
"id" text PRIMARY KEY NOT NULL,
"identifier" varchar(255) NOT NULL,
"value" varchar(255) NOT NULL,
"expiresAt" timestamp NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "beenvoice_account" ADD CONSTRAINT "beenvoice_account_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_business" ADD CONSTRAINT "beenvoice_business_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_client" ADD CONSTRAINT "beenvoice_client_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_session" ADD CONSTRAINT "beenvoice_session_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_sso_provider" ADD CONSTRAINT "beenvoice_sso_provider_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "beenvoice_account" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "business_created_by_idx" ON "beenvoice_business" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "business_name_idx" ON "beenvoice_business" USING btree ("name");--> statement-breakpoint
CREATE INDEX "business_nickname_idx" ON "beenvoice_business" USING btree ("nickname");--> statement-breakpoint
CREATE INDEX "business_email_idx" ON "beenvoice_business" USING btree ("email");--> statement-breakpoint
CREATE INDEX "business_is_default_idx" ON "beenvoice_business" USING btree ("isDefault");--> statement-breakpoint
CREATE INDEX "client_created_by_idx" ON "beenvoice_client" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "client_name_idx" ON "beenvoice_client" USING btree ("name");--> statement-breakpoint
CREATE INDEX "client_email_idx" ON "beenvoice_client" USING btree ("email");--> statement-breakpoint
CREATE INDEX "invoice_item_invoice_id_idx" ON "beenvoice_invoice_item" USING btree ("invoiceId");--> statement-breakpoint
CREATE INDEX "invoice_item_date_idx" ON "beenvoice_invoice_item" USING btree ("date");--> statement-breakpoint
CREATE INDEX "invoice_item_position_idx" ON "beenvoice_invoice_item" USING btree ("position");--> statement-breakpoint
CREATE INDEX "invoice_business_id_idx" ON "beenvoice_invoice" USING btree ("businessId");--> statement-breakpoint
CREATE INDEX "invoice_client_id_idx" ON "beenvoice_invoice" USING btree ("clientId");--> statement-breakpoint
CREATE INDEX "invoice_created_by_idx" ON "beenvoice_invoice" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "invoice_number_idx" ON "beenvoice_invoice" USING btree ("invoiceNumber");--> statement-breakpoint
CREATE INDEX "invoice_status_idx" ON "beenvoice_invoice" USING btree ("status");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "beenvoice_session" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "sso_provider_user_id_idx" ON "beenvoice_sso_provider" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "verification_token_identifier_idx" ON "beenvoice_verification_token" USING btree ("identifier");
@@ -0,0 +1,43 @@
CREATE TABLE "beenvoice_expense" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"businessId" varchar(255),
"clientId" varchar(255),
"invoiceId" varchar(255),
"date" timestamp NOT NULL,
"description" varchar(500) NOT NULL,
"amount" real NOT NULL,
"currency" varchar(3) DEFAULT 'USD' NOT NULL,
"category" varchar(100),
"billable" boolean DEFAULT false NOT NULL,
"reimbursable" boolean DEFAULT false NOT NULL,
"notes" varchar(500),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_invoice_template" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"type" varchar(50) DEFAULT 'notes' NOT NULL,
"content" text NOT NULL,
"isDefault" boolean DEFAULT false NOT NULL,
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
ALTER TABLE "beenvoice_client" ADD COLUMN "currency" varchar(3) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD COLUMN "currency" varchar(3) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_template" ADD CONSTRAINT "beenvoice_invoice_template_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "expense_created_by_idx" ON "beenvoice_expense" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "expense_client_id_idx" ON "beenvoice_expense" USING btree ("clientId");--> statement-breakpoint
CREATE INDEX "expense_invoice_id_idx" ON "beenvoice_expense" USING btree ("invoiceId");--> statement-breakpoint
CREATE INDEX "expense_date_idx" ON "beenvoice_expense" USING btree ("date");--> statement-breakpoint
CREATE INDEX "expense_billable_idx" ON "beenvoice_expense" USING btree ("billable");--> statement-breakpoint
CREATE INDEX "invoice_template_created_by_idx" ON "beenvoice_invoice_template" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "invoice_template_type_idx" ON "beenvoice_invoice_template" USING btree ("type");
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_user" ADD COLUMN "interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL;
ALTER TABLE "beenvoice_user" ADD COLUMN "fontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_user"
ADD COLUMN "bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_user"
ADD COLUMN "headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_user"
ADD COLUMN "radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_user"
ADD COLUMN "sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL;
@@ -0,0 +1,59 @@
ALTER TABLE "beenvoice_user"
ADD COLUMN "role" varchar(20) DEFAULT 'user' NOT NULL;
--> statement-breakpoint
UPDATE "beenvoice_user"
SET "role" = 'admin'
WHERE "id" = (
SELECT "id"
FROM "beenvoice_user"
ORDER BY "createdAt" ASC
LIMIT 1
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "beenvoice_platform_setting" (
"id" varchar(50) PRIMARY KEY DEFAULT 'global' NOT NULL,
"brandName" varchar(100) DEFAULT 'beenvoice' NOT NULL,
"brandTagline" varchar(255) DEFAULT 'Simple and efficient invoicing for freelancers and small businesses' NOT NULL,
"brandLogoText" varchar(100) DEFAULT 'beenvoice' NOT NULL,
"brandIcon" varchar(20) DEFAULT '$' NOT NULL,
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
"customColor" varchar(50),
"theme" varchar(20) DEFAULT 'system' NOT NULL,
"interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL,
"bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
"headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
"radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL,
"sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
INSERT INTO "beenvoice_platform_setting" (
"id",
"brandName",
"brandTagline",
"brandLogoText",
"brandIcon",
"colorTheme",
"customColor",
"theme",
"interfaceTheme",
"bodyFontPreference",
"headingFontPreference",
"radiusPreference",
"sidebarStyle"
) VALUES (
'global',
'beenvoice',
'Simple and efficient invoicing for freelancers and small businesses',
'beenvoice',
'$',
'slate',
NULL,
'system',
'beenvoice',
'brand',
'brand',
'xl',
'floating'
) ON CONFLICT ("id") DO NOTHING;
@@ -0,0 +1,14 @@
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfTemplate" varchar(20) DEFAULT 'classic' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfAccentColor" varchar(50) DEFAULT '#111827' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfFooterText" varchar(120) DEFAULT 'Professional Invoicing' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfShowLogo" boolean DEFAULT true NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfShowPageNumbers" boolean DEFAULT true NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_invoice"
ADD COLUMN "emailMessage" varchar(2000);
@@ -0,0 +1,125 @@
-- New columns on beenvoice_invoice
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "invoicePrefix" varchar(20) DEFAULT '#';
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "publicToken" varchar(255);
--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "lastReminderSentAt" timestamp;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_invoice_publicToken_unique'
) THEN
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_publicToken_unique" UNIQUE("publicToken");
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "invoice_public_token_idx" ON "beenvoice_invoice" USING btree ("publicToken");
--> statement-breakpoint
-- Partial payment tracking
CREATE TABLE IF NOT EXISTS "beenvoice_invoice_payment" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"invoiceId" varchar(255) NOT NULL,
"amount" real NOT NULL,
"currency" varchar(3) DEFAULT 'USD' NOT NULL,
"date" timestamp NOT NULL,
"method" varchar(50) DEFAULT 'other' NOT NULL,
"notes" varchar(500),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_invoice_payment_invoiceId_beenvoice_invoice_id_fk'
) THEN
ALTER TABLE "beenvoice_invoice_payment" ADD CONSTRAINT "beenvoice_invoice_payment_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_invoice_payment_createdById_beenvoice_user_id_fk'
) THEN
ALTER TABLE "beenvoice_invoice_payment" ADD CONSTRAINT "beenvoice_invoice_payment_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "invoice_payment_invoice_id_idx" ON "beenvoice_invoice_payment" USING btree ("invoiceId");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "invoice_payment_created_by_idx" ON "beenvoice_invoice_payment" USING btree ("createdById");
--> statement-breakpoint
-- Recurring invoices
CREATE TABLE IF NOT EXISTS "beenvoice_recurring_invoice" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"clientId" varchar(255) NOT NULL,
"businessId" varchar(255),
"schedule" varchar(20) DEFAULT 'monthly' NOT NULL,
"status" varchar(20) DEFAULT 'active' NOT NULL,
"invoicePrefix" varchar(20) DEFAULT '#',
"taxRate" real DEFAULT 0 NOT NULL,
"currency" varchar(3) DEFAULT 'USD' NOT NULL,
"notes" varchar(1000),
"emailMessage" varchar(2000),
"nextDueAt" timestamp NOT NULL,
"lastGeneratedAt" timestamp,
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_recurring_invoice_clientId_beenvoice_client_id_fk'
) THEN
ALTER TABLE "beenvoice_recurring_invoice" ADD CONSTRAINT "beenvoice_recurring_invoice_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_recurring_invoice_businessId_beenvoice_business_id_fk'
) THEN
ALTER TABLE "beenvoice_recurring_invoice" ADD CONSTRAINT "beenvoice_recurring_invoice_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_recurring_invoice_createdById_beenvoice_user_id_fk'
) THEN
ALTER TABLE "beenvoice_recurring_invoice" ADD CONSTRAINT "beenvoice_recurring_invoice_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "recurring_invoice_created_by_idx" ON "beenvoice_recurring_invoice" USING btree ("createdById");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "recurring_invoice_client_id_idx" ON "beenvoice_recurring_invoice" USING btree ("clientId");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "recurring_invoice_status_idx" ON "beenvoice_recurring_invoice" USING btree ("status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "recurring_invoice_next_due_idx" ON "beenvoice_recurring_invoice" USING btree ("nextDueAt");
--> statement-breakpoint
-- Recurring invoice line items
CREATE TABLE IF NOT EXISTS "beenvoice_recurring_invoice_item" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"recurringInvoiceId" varchar(255) NOT NULL,
"description" varchar(500) NOT NULL,
"hours" real NOT NULL,
"rate" real NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_recurring_invoice_item_recurringInvoiceId_beenvoice_recurring_invoice_id_fk'
) THEN
ALTER TABLE "beenvoice_recurring_invoice_item" ADD CONSTRAINT "beenvoice_recurring_invoice_item_recurringInvoiceId_beenvoice_recurring_invoice_id_fk" FOREIGN KEY ("recurringInvoiceId") REFERENCES "public"."beenvoice_recurring_invoice"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "recurring_invoice_item_recurring_id_idx" ON "beenvoice_recurring_invoice_item" USING btree ("recurringInvoiceId");
+27
View File
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS "beenvoice_api_key" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(100) NOT NULL,
"keyHash" varchar(64) NOT NULL,
"keyPrefix" varchar(16) NOT NULL,
"userId" varchar(255) NOT NULL,
"lastUsedAt" timestamp,
"expiresAt" timestamp,
"revokedAt" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "beenvoice_api_key_keyHash_unique" UNIQUE("keyHash")
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_api_key_userId_beenvoice_user_id_fk'
) THEN
ALTER TABLE "beenvoice_api_key" ADD CONSTRAINT "beenvoice_api_key_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "api_key_hash_idx" ON "beenvoice_api_key" USING btree ("keyHash");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "api_key_user_id_idx" ON "beenvoice_api_key" USING btree ("userId");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "api_key_revoked_at_idx" ON "beenvoice_api_key" USING btree ("revokedAt");
+37
View File
@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS "beenvoice_time_entry" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"description" varchar(500) DEFAULT '' NOT NULL,
"clientId" varchar(255),
"startedAt" timestamp NOT NULL,
"endedAt" timestamp,
"hours" real,
"rate" real,
"notes" varchar(500),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_time_entry_clientId_beenvoice_client_id_fk'
) THEN
ALTER TABLE "beenvoice_time_entry" ADD CONSTRAINT "beenvoice_time_entry_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_time_entry_createdById_beenvoice_user_id_fk'
) THEN
ALTER TABLE "beenvoice_time_entry" ADD CONSTRAINT "beenvoice_time_entry_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "time_entry_created_by_idx" ON "beenvoice_time_entry" USING btree ("createdById");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "time_entry_client_id_idx" ON "beenvoice_time_entry" USING btree ("clientId");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "time_entry_started_at_idx" ON "beenvoice_time_entry" USING btree ("startedAt");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "time_entry_ended_at_idx" ON "beenvoice_time_entry" USING btree ("endedAt");
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_time_entry" ADD COLUMN IF NOT EXISTS "invoiceId" varchar(255);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_time_entry_invoiceId_beenvoice_invoice_id_fk'
) THEN
ALTER TABLE "beenvoice_time_entry" ADD CONSTRAINT "beenvoice_time_entry_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "time_entry_invoice_id_idx" ON "beenvoice_time_entry" USING btree ("invoiceId");
@@ -0,0 +1 @@
ALTER TABLE "beenvoice_verification_token" ALTER COLUMN "value" TYPE text;
@@ -0,0 +1 @@
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "publicTokenExpiresAt" timestamp;
+172
View File
@@ -0,0 +1,172 @@
-- App Store review demo account: demo@example.com / demo123
DO $$
DECLARE
demo_user_id varchar(255) := 'a0000000-0000-4000-8000-000000000001';
demo_account_id text := 'a0000000-0000-4000-8000-000000000010';
demo_business_id varchar(255) := 'a0000000-0000-4000-8000-000000000020';
demo_client_acme varchar(255) := 'a0000000-0000-4000-8000-000000000030';
demo_client_bright varchar(255) := 'a0000000-0000-4000-8000-000000000031';
demo_invoice_draft varchar(255) := 'a0000000-0000-4000-8000-000000000040';
demo_invoice_sent varchar(255) := 'a0000000-0000-4000-8000-000000000041';
demo_invoice_paid varchar(255) := 'a0000000-0000-4000-8000-000000000042';
demo_password_hash text := '$2b$12$90U31okgkhOwSQD5RDqHwO0QpcC.pkKsqKb1IPnHfKUZm/2A9hzs6';
BEGIN
IF EXISTS (SELECT 1 FROM "beenvoice_user" WHERE "email" = 'demo@example.com') THEN
RETURN;
END IF;
INSERT INTO "beenvoice_user" (
"id", "name", "email", "emailVerified", "password", "role"
) VALUES (
demo_user_id,
'Demo User',
'demo@example.com',
true,
demo_password_hash,
'user'
);
INSERT INTO "beenvoice_account" (
"id", "userId", "accountId", "providerId", "password"
) VALUES (
demo_account_id,
demo_user_id,
demo_user_id,
'credential',
demo_password_hash
);
INSERT INTO "beenvoice_business" (
"id", "name", "nickname", "email", "phone", "addressLine1", "city", "state",
"postalCode", "country", "isDefault", "createdById"
) VALUES (
demo_business_id,
'Demo Studio LLC',
'Demo Studio',
'hello@demostudio.example',
'(555) 010-2000',
'100 Market Street',
'San Francisco',
'CA',
'94105',
'United States',
true,
demo_user_id
);
INSERT INTO "beenvoice_client" (
"id", "name", "email", "phone", "defaultHourlyRate", "currency", "createdById"
) VALUES
(
demo_client_acme,
'Acme Corporation',
'billing@acme.example',
'(555) 010-3001',
150,
'USD',
demo_user_id
),
(
demo_client_bright,
'Bright Labs',
'ap@brightlabs.example',
'(555) 010-3002',
125,
'USD',
demo_user_id
);
INSERT INTO "beenvoice_invoice" (
"id", "invoiceNumber", "invoicePrefix", "businessId", "clientId",
"issueDate", "dueDate", "status", "totalAmount", "taxRate", "notes", "currency", "createdById"
) VALUES
(
demo_invoice_draft,
'INV-DEMO-001',
'#',
demo_business_id,
demo_client_acme,
NOW() - INTERVAL '5 days',
NOW() + INTERVAL '25 days',
'draft',
1500,
0,
'Website redesign — phase 1',
'USD',
demo_user_id
),
(
demo_invoice_sent,
'INV-DEMO-002',
'#',
demo_business_id,
demo_client_bright,
NOW() - INTERVAL '20 days',
NOW() - INTERVAL '5 days',
'sent',
2500,
0,
'Mobile app consulting',
'USD',
demo_user_id
),
(
demo_invoice_paid,
'INV-DEMO-003',
'#',
demo_business_id,
demo_client_acme,
NOW() - INTERVAL '45 days',
NOW() - INTERVAL '15 days',
'paid',
3200,
0,
'API integration project',
'USD',
demo_user_id
);
INSERT INTO "beenvoice_invoice_item" (
"id", "invoiceId", "date", "description", "hours", "rate", "amount", "position"
) VALUES
(
'a0000000-0000-4000-8000-000000000050',
demo_invoice_draft,
NOW() - INTERVAL '5 days',
'UX wireframes and design system',
10,
150,
1500,
0
),
(
'a0000000-0000-4000-8000-000000000051',
demo_invoice_sent,
NOW() - INTERVAL '20 days',
'Sprint planning and implementation',
20,
125,
2500,
0
),
(
'a0000000-0000-4000-8000-000000000052',
demo_invoice_paid,
NOW() - INTERVAL '45 days',
'Backend API work',
16,
150,
2400,
0
),
(
'a0000000-0000-4000-8000-000000000053',
demo_invoice_paid,
NOW() - INTERVAL '44 days',
'Deployment and documentation',
5.33,
150,
800,
1
);
END $$;
@@ -0,0 +1 @@
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "sendReminderAt" timestamp;
@@ -0,0 +1,3 @@
-- 0015 may have been recorded before the column name was corrected (send_reminder_at vs sendReminderAt).
ALTER TABLE "beenvoice_invoice" DROP COLUMN IF EXISTS "send_reminder_at";
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "sendReminderAt" timestamp;
@@ -0,0 +1,39 @@
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "colorTheme";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "customColor";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "interfaceTheme";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "fontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "bodyFontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "headingFontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "radiusPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "sidebarStyle";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandName";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandTagline";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandLogoText";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandIcon";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "colorTheme";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "customColor";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "theme";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "interfaceTheme";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "bodyFontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "headingFontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "radiusPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "sidebarStyle";
+11
View File
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_user" ADD COLUMN IF NOT EXISTS "onboardingCompletedAt" timestamp;
-- Users who already have a business are treated as onboarded
UPDATE "beenvoice_user" u
SET "onboardingCompletedAt" = COALESCE(u."onboardingCompletedAt", NOW())
WHERE u."onboardingCompletedAt" IS NULL
AND EXISTS (
SELECT 1
FROM "beenvoice_business" b
WHERE b."createdById" = u."id"
);
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfFontFamily" varchar(20) DEFAULT 'sans' NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfNumericFontFamily" varchar(20) DEFAULT 'mono' NOT NULL;
+20
View File
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS "beenvoice_audit_log" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"actorUserId" varchar(255) NOT NULL,
"action" varchar(100) NOT NULL,
"targetType" varchar(50) NOT NULL,
"targetId" varchar(255),
"metadata" jsonb,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "beenvoice_audit_log"
ADD CONSTRAINT "beenvoice_audit_log_actorUserId_beenvoice_user_id_fk"
FOREIGN KEY ("actorUserId") REFERENCES "public"."beenvoice_user"("id")
ON DELETE NO ACTION ON UPDATE NO ACTION;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_actor_user_id_idx" ON "beenvoice_audit_log" USING btree ("actorUserId");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_action_idx" ON "beenvoice_audit_log" USING btree ("action");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "audit_log_created_at_idx" ON "beenvoice_audit_log" USING btree ("createdAt");
@@ -0,0 +1,43 @@
CREATE INDEX IF NOT EXISTS "expense_business_id_idx" ON "beenvoice_expense" USING btree ("businessId");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "beenvoice_expense_receipt" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"expenseId" varchar(255) NOT NULL,
"storageKey" varchar(500) NOT NULL,
"originalFilename" varchar(255) NOT NULL,
"mimeType" varchar(100) NOT NULL,
"sizeBytes" integer NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "beenvoice_expense_receipt"
ADD CONSTRAINT "beenvoice_expense_receipt_expenseId_beenvoice_expense_id_fk"
FOREIGN KEY ("expenseId") REFERENCES "public"."beenvoice_expense"("id")
ON DELETE CASCADE ON UPDATE NO ACTION;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "expense_receipt_expense_id_idx" ON "beenvoice_expense_receipt" USING btree ("expenseId");
--> statement-breakpoint
UPDATE "beenvoice_expense" e
SET "businessId" = i."businessId"
FROM "beenvoice_invoice" i
WHERE e."invoiceId" = i.id
AND e."businessId" IS NULL
AND i."businessId" IS NOT NULL;
--> statement-breakpoint
UPDATE "beenvoice_expense" e
SET "businessId" = sub.business_id
FROM (
SELECT
e2.id AS expense_id,
(
SELECT b2.id
FROM "beenvoice_business" b2
WHERE b2."createdById" = e2."createdById"
ORDER BY b2."isDefault" DESC, b2."createdAt" DESC
LIMIT 1
) AS business_id
FROM "beenvoice_expense" e2
WHERE e2."businessId" IS NULL
) sub
WHERE e.id = sub.expense_id
AND sub.business_id IS NOT NULL;
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_invoice_item" ADD COLUMN IF NOT EXISTS "timeEntryId" varchar(255);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk'
) THEN
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk" FOREIGN KEY ("timeEntryId") REFERENCES "public"."beenvoice_time_entry"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "invoice_item_time_entry_id_idx" ON "beenvoice_invoice_item" USING btree ("timeEntryId") WHERE "timeEntryId" is not null;
@@ -0,0 +1,3 @@
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoStorageKey" varchar(500);
--> statement-breakpoint
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoMimeType" varchar(100);
@@ -0,0 +1 @@
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "hideNameWithLogo" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,8 @@
-- 0025 was silently skipped on databases that already had a later migration
-- timestamp recorded (from a since-removed feature's migrations). Re-apply
-- idempotently so both that DB and any fresh install end up with the column.
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "hideNameWithLogo" boolean DEFAULT false NOT NULL;
--> statement-breakpoint
-- Drop the orphaned table from the business-document-vault feature that was
-- removed earlier this session (its migrations ran on this DB before removal).
DROP TABLE IF EXISTS "beenvoice_business_document" CASCADE;
@@ -0,0 +1,17 @@
-- The original App Review credential was committed publicly in migration 0014.
-- Rotate it to an unknown value and invalidate its sessions. Use
-- `bun run demo:provision` with a private DEMO_ACCOUNT_PASSWORD when review
-- access is needed.
UPDATE "beenvoice_user"
SET "password" = '$2b$12$GyM6.bLv2.sZMsWytNz1L.j7pLwc79a55Nww6bSLQJ9OJarqY9oZW',
"updatedAt" = NOW()
WHERE "id" = 'a0000000-0000-4000-8000-000000000001';
UPDATE "beenvoice_account"
SET "password" = '$2b$12$GyM6.bLv2.sZMsWytNz1L.j7pLwc79a55Nww6bSLQJ9OJarqY9oZW',
"updatedAt" = NOW()
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001'
AND "providerId" = 'credential';
DELETE FROM "beenvoice_session"
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001';
@@ -0,0 +1,16 @@
-- Restore the public App Store review credential for the seeded demo account.
-- Password: demo123
UPDATE "beenvoice_user"
SET "password" = '$2b$12$90U31okgkhOwSQD5RDqHwO0QpcC.pkKsqKb1IPnHfKUZm/2A9hzs6',
"updatedAt" = NOW()
WHERE "id" = 'a0000000-0000-4000-8000-000000000001'
AND "email" = 'demo@example.com';
UPDATE "beenvoice_account"
SET "password" = '$2b$12$90U31okgkhOwSQD5RDqHwO0QpcC.pkKsqKb1IPnHfKUZm/2A9hzs6',
"updatedAt" = NOW()
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001'
AND "providerId" = 'credential';
DELETE FROM "beenvoice_session"
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1775354242672,
"tag": "0000_glossy_magneto",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1775356013998,
"tag": "0001_supreme_the_enforcers",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1775400000000,
"tag": "0002_tax_deductible",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1775600000000,
"tag": "0003_appearance_preferences",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1777336000000,
"tag": "0004_platform_appearance_controls",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1777337000000,
"tag": "0005_platform_settings_and_roles",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1777338000000,
"tag": "0006_pdf_generation_settings",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1777339000000,
"tag": "0007_invoice_email_message",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1747526400000,
"tag": "0008_payments_recurring_public_links",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1780617600000,
"tag": "0009_api_keys",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1780704000000,
"tag": "0010_time_entries",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1749254400000,
"tag": "0011_time_entry_invoice_id",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1749340800000,
"tag": "0012_verification_token_value_text",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1781194385000,
"tag": "0013_invoice_public_token_expiry",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1781300000000,
"tag": "0014_seed_demo_account",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1781400000000,
"tag": "0015_invoice_send_reminder_at",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1781500000000,
"tag": "0016_fix_send_reminder_at_column",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1781600000000,
"tag": "0017_drop_theme_engine_columns",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1781700000000,
"tag": "0018_user_onboarding",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1781800000000,
"tag": "0019_pdf_font_family",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1781900000000,
"tag": "0020_pdf_numeric_font_family",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1782000000000,
"tag": "0021_audit_log",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1782100000000,
"tag": "0022_expense_business_receipts",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1782200000000,
"tag": "0023_invoice_item_time_entry",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1783700000000,
"tag": "0024_business_logo_upload",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1783800000000,
"tag": "0025_business_hide_name_with_logo",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1784000000000,
"tag": "0026_business_hide_name_with_logo_fix",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1786740000000,
"tag": "0027_disable_public_demo_password",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1786766968000,
"tag": "0028_enable_public_demo_password",
"breakpoints": true
}
]
}
+57
View File
@@ -0,0 +1,57 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import tseslint from "typescript-eslint";
// @ts-ignore -- no types for this plugin
import drizzle from "eslint-plugin-drizzle";
export default tseslint.config(
{
ignores: [".next", "scripts/**"],
},
...nextCoreWebVitals,
{
files: ["**/*.ts", "**/*.tsx"],
plugins: {
drizzle,
},
extends: [
...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
rules: {
"@typescript-eslint/array-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": [
"warn",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"drizzle/enforce-delete-with-where": [
"error",
{ drizzleObjectName: ["db", "ctx.db"] },
],
"drizzle/enforce-update-with-where": [
"error",
{ drizzleObjectName: ["db", "ctx.db"] },
],
},
},
{
linterOptions: {
reportUnusedDisableDirectives: true,
},
languageOptions: {
parserOptions: {
projectService: true,
},
},
},
);
+28
View File
@@ -0,0 +1,28 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import "./src/env.js";
const isDockerBuild = process.env.DOCKER_BUILD === "1";
const disableReactCompiler = process.env.DISABLE_REACT_COMPILER === "1";
/** @type {import("next").NextConfig} */
const config = {
// React Compiler is helpful in dev/prod but adds compile-time memory pressure in Docker builds.
reactCompiler: !disableReactCompiler,
productionBrowserSourceMaps: false,
serverExternalPackages: ["pg", "better-auth"],
experimental: {
webpackMemoryOptimizations: true,
},
// Skip duplicate typecheck during Docker `next build` to lower peak memory.
// Lint separately via `bun run lint` / `bun run check`.
...(isDockerBuild
? {
typescript: { ignoreBuildErrors: true },
}
: {}),
};
export default config;
+130
View File
@@ -0,0 +1,130 @@
{
"name": "beenvoice",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"check": "eslint . && tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "bun src/server/db/migrate.ts",
"db:verify-journal": "bun scripts/verify-drizzle-journal.ts",
"db:push": "drizzle-kit push",
"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",
"deploy": "drizzle-kit push && next build",
"dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"preview": "next build && next start",
"start": "next start",
"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",
"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"
},
"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",
"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"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"trustedDependencies": [
"@tailwindcss/oxide",
"core-js",
"esbuild",
"sharp",
"unrs-resolver"
]
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
const config = {
plugins: ["prettier-plugin-tailwindcss"],
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 2970 436" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-15.2844,-243.314)">
<g transform="matrix(1,0,0,1,-42.8493,2.19437)">
<g transform="matrix(1.05907,0,0,1.05907,-1187.92,22.2126)">
<g transform="matrix(460.901,0,0,460.901,1157.01,576.339)">
<path d="M0.262,0.09L0.262,-0.802L0.341,-0.802L0.341,0.09L0.262,0.09ZM0.307,0.012C0.255,0.012 0.21,0.002 0.171,-0.018C0.133,-0.038 0.103,-0.066 0.081,-0.103C0.059,-0.14 0.046,-0.184 0.042,-0.236L0.164,-0.243C0.169,-0.21 0.177,-0.183 0.19,-0.162C0.202,-0.14 0.219,-0.123 0.239,-0.112C0.259,-0.101 0.283,-0.096 0.311,-0.096C0.34,-0.096 0.364,-0.099 0.383,-0.106C0.402,-0.113 0.416,-0.123 0.425,-0.136C0.435,-0.149 0.44,-0.165 0.44,-0.184C0.44,-0.204 0.435,-0.221 0.426,-0.236C0.417,-0.25 0.4,-0.262 0.375,-0.274C0.349,-0.285 0.313,-0.297 0.265,-0.308C0.219,-0.32 0.181,-0.334 0.15,-0.352C0.12,-0.369 0.097,-0.391 0.082,-0.417C0.067,-0.443 0.059,-0.474 0.059,-0.51C0.059,-0.551 0.068,-0.587 0.087,-0.617C0.106,-0.647 0.133,-0.671 0.169,-0.687C0.205,-0.704 0.248,-0.712 0.299,-0.712C0.349,-0.712 0.392,-0.703 0.427,-0.685C0.463,-0.667 0.491,-0.641 0.511,-0.607C0.531,-0.573 0.544,-0.533 0.548,-0.486L0.426,-0.48C0.422,-0.506 0.416,-0.528 0.406,-0.547C0.396,-0.565 0.382,-0.58 0.364,-0.59C0.346,-0.599 0.323,-0.604 0.295,-0.604C0.257,-0.604 0.227,-0.597 0.207,-0.581C0.186,-0.565 0.175,-0.543 0.175,-0.516C0.175,-0.496 0.18,-0.48 0.188,-0.468C0.197,-0.455 0.212,-0.444 0.235,-0.435C0.257,-0.425 0.289,-0.415 0.33,-0.404C0.387,-0.389 0.432,-0.372 0.465,-0.353C0.498,-0.333 0.522,-0.31 0.536,-0.284C0.55,-0.257 0.558,-0.225 0.558,-0.187C0.558,-0.146 0.547,-0.111 0.527,-0.081C0.507,-0.051 0.478,-0.028 0.44,-0.012C0.403,0.004 0.359,0.012 0.307,0.012Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,1515.12,576.339)">
<path d="M0.35,0.012C0.312,0.012 0.28,0.003 0.252,-0.014C0.225,-0.032 0.204,-0.055 0.189,-0.083L0.186,-0L0.074,-0L0.074,-0.71L0.192,-0.71L0.192,-0.459C0.206,-0.483 0.226,-0.504 0.254,-0.521C0.281,-0.538 0.313,-0.546 0.35,-0.546C0.394,-0.546 0.433,-0.535 0.466,-0.513C0.498,-0.49 0.523,-0.458 0.541,-0.417C0.559,-0.375 0.568,-0.325 0.568,-0.267C0.568,-0.209 0.559,-0.159 0.541,-0.117C0.523,-0.076 0.498,-0.044 0.466,-0.021C0.433,0.001 0.394,0.012 0.35,0.012ZM0.322,-0.094C0.362,-0.094 0.392,-0.11 0.413,-0.14C0.434,-0.17 0.445,-0.212 0.445,-0.267C0.445,-0.322 0.434,-0.364 0.413,-0.395C0.392,-0.425 0.362,-0.44 0.324,-0.44C0.297,-0.44 0.274,-0.433 0.254,-0.42C0.234,-0.406 0.219,-0.387 0.208,-0.361C0.198,-0.335 0.192,-0.304 0.192,-0.267C0.192,-0.231 0.198,-0.2 0.209,-0.174C0.219,-0.148 0.234,-0.129 0.254,-0.115C0.273,-0.101 0.296,-0.094 0.322,-0.094Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,1791.66,576.339)">
<path d="M0.305,0.012C0.255,0.012 0.212,0.001 0.174,-0.022C0.136,-0.045 0.107,-0.077 0.086,-0.119C0.065,-0.161 0.055,-0.21 0.055,-0.267C0.055,-0.323 0.065,-0.371 0.086,-0.413C0.107,-0.455 0.136,-0.487 0.173,-0.511C0.21,-0.534 0.253,-0.546 0.303,-0.546C0.351,-0.546 0.394,-0.535 0.431,-0.512C0.468,-0.489 0.496,-0.457 0.517,-0.415C0.538,-0.373 0.549,-0.323 0.549,-0.265L0.549,-0.234L0.177,-0.234C0.181,-0.188 0.194,-0.154 0.217,-0.13C0.24,-0.106 0.27,-0.094 0.307,-0.094C0.336,-0.094 0.359,-0.101 0.378,-0.115C0.397,-0.128 0.41,-0.146 0.418,-0.168L0.539,-0.159C0.522,-0.106 0.494,-0.064 0.454,-0.034C0.414,-0.003 0.364,0.012 0.305,0.012ZM0.178,-0.32L0.421,-0.32C0.418,-0.361 0.405,-0.391 0.384,-0.41C0.362,-0.43 0.335,-0.44 0.302,-0.44C0.268,-0.44 0.241,-0.429 0.219,-0.409C0.198,-0.389 0.184,-0.359 0.178,-0.32Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,2068.19,576.339)">
<path d="M0.305,0.012C0.255,0.012 0.212,0.001 0.174,-0.022C0.136,-0.045 0.107,-0.077 0.086,-0.119C0.065,-0.161 0.055,-0.21 0.055,-0.267C0.055,-0.323 0.065,-0.371 0.086,-0.413C0.107,-0.455 0.136,-0.487 0.173,-0.511C0.21,-0.534 0.253,-0.546 0.303,-0.546C0.351,-0.546 0.394,-0.535 0.431,-0.512C0.468,-0.489 0.496,-0.457 0.517,-0.415C0.538,-0.373 0.549,-0.323 0.549,-0.265L0.549,-0.234L0.177,-0.234C0.181,-0.188 0.194,-0.154 0.217,-0.13C0.24,-0.106 0.27,-0.094 0.307,-0.094C0.336,-0.094 0.359,-0.101 0.378,-0.115C0.397,-0.128 0.41,-0.146 0.418,-0.168L0.539,-0.159C0.522,-0.106 0.494,-0.064 0.454,-0.034C0.414,-0.003 0.364,0.012 0.305,0.012ZM0.178,-0.32L0.421,-0.32C0.418,-0.361 0.405,-0.391 0.384,-0.41C0.362,-0.43 0.335,-0.44 0.302,-0.44C0.268,-0.44 0.241,-0.429 0.219,-0.409C0.198,-0.389 0.184,-0.359 0.178,-0.32Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,2344.73,576.339)">
<path d="M0.076,-0L0.076,-0.534L0.184,-0.534L0.188,-0.391L0.176,-0.398C0.182,-0.432 0.194,-0.46 0.21,-0.482C0.227,-0.504 0.248,-0.52 0.272,-0.53C0.296,-0.541 0.323,-0.546 0.351,-0.546C0.391,-0.546 0.423,-0.537 0.448,-0.52C0.473,-0.502 0.492,-0.478 0.505,-0.448C0.518,-0.418 0.524,-0.384 0.524,-0.345L0.524,-0L0.406,-0L0.406,-0.317C0.406,-0.36 0.398,-0.392 0.382,-0.413C0.367,-0.434 0.343,-0.445 0.311,-0.445C0.29,-0.445 0.27,-0.44 0.253,-0.43C0.235,-0.42 0.221,-0.405 0.21,-0.385C0.2,-0.366 0.194,-0.342 0.194,-0.313L0.194,-0L0.076,-0Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,2621.27,576.339)">
<path d="M0.227,-0L0.04,-0.534L0.167,-0.534L0.3,-0.128L0.433,-0.534L0.56,-0.534L0.373,-0L0.227,-0Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,2897.8,576.339)">
<path d="M0.3,0.012C0.25,0.012 0.206,0.001 0.169,-0.022C0.131,-0.045 0.102,-0.077 0.081,-0.119C0.06,-0.161 0.05,-0.21 0.05,-0.267C0.05,-0.324 0.06,-0.373 0.081,-0.415C0.102,-0.457 0.131,-0.489 0.169,-0.512C0.206,-0.535 0.25,-0.546 0.3,-0.546C0.35,-0.546 0.394,-0.535 0.431,-0.512C0.469,-0.489 0.498,-0.457 0.519,-0.415C0.54,-0.373 0.55,-0.324 0.55,-0.267C0.55,-0.21 0.54,-0.161 0.519,-0.119C0.498,-0.077 0.469,-0.045 0.431,-0.022C0.394,0.001 0.35,0.012 0.3,0.012ZM0.3,-0.094C0.34,-0.094 0.372,-0.11 0.394,-0.14C0.416,-0.17 0.427,-0.212 0.427,-0.267C0.427,-0.322 0.416,-0.364 0.394,-0.395C0.372,-0.425 0.34,-0.44 0.3,-0.44C0.26,-0.44 0.228,-0.425 0.206,-0.395C0.184,-0.364 0.173,-0.322 0.173,-0.267C0.173,-0.212 0.184,-0.17 0.206,-0.14C0.228,-0.11 0.26,-0.094 0.3,-0.094Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,3174.34,576.339)">
<path d="M0.276,-0L0.276,-0.534L0.394,-0.534L0.394,-0L0.276,-0ZM0.072,-0L0.072,-0.096L0.568,-0.096L0.568,-0L0.072,-0ZM0.082,-0.438L0.082,-0.534L0.377,-0.534L0.377,-0.438L0.082,-0.438ZM0.271,-0.605L0.271,-0.717L0.391,-0.717L0.391,-0.605L0.271,-0.605Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,3450.88,576.339)">
<path d="M0.311,0.012C0.26,0.012 0.216,0 0.178,-0.023C0.14,-0.046 0.11,-0.079 0.089,-0.121C0.068,-0.162 0.057,-0.211 0.057,-0.267C0.057,-0.323 0.068,-0.371 0.089,-0.413C0.11,-0.455 0.14,-0.487 0.178,-0.511C0.216,-0.534 0.26,-0.546 0.311,-0.546C0.352,-0.546 0.389,-0.538 0.422,-0.522C0.456,-0.506 0.483,-0.483 0.505,-0.454C0.526,-0.424 0.54,-0.389 0.546,-0.348L0.427,-0.341C0.42,-0.373 0.406,-0.397 0.386,-0.414C0.366,-0.431 0.341,-0.44 0.312,-0.44C0.271,-0.44 0.239,-0.424 0.215,-0.394C0.192,-0.363 0.18,-0.321 0.18,-0.267C0.18,-0.213 0.192,-0.171 0.215,-0.141C0.239,-0.11 0.271,-0.094 0.312,-0.094C0.341,-0.094 0.367,-0.103 0.388,-0.121C0.409,-0.139 0.423,-0.165 0.43,-0.2L0.549,-0.193C0.543,-0.152 0.528,-0.116 0.507,-0.085C0.485,-0.055 0.457,-0.031 0.423,-0.014C0.39,0.003 0.352,0.012 0.311,0.012Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,3727.41,576.339)">
<path d="M0.305,0.012C0.255,0.012 0.212,0.001 0.174,-0.022C0.136,-0.045 0.107,-0.077 0.086,-0.119C0.065,-0.161 0.055,-0.21 0.055,-0.267C0.055,-0.323 0.065,-0.371 0.086,-0.413C0.107,-0.455 0.136,-0.487 0.173,-0.511C0.21,-0.534 0.253,-0.546 0.303,-0.546C0.351,-0.546 0.394,-0.535 0.431,-0.512C0.468,-0.489 0.496,-0.457 0.517,-0.415C0.538,-0.373 0.549,-0.323 0.549,-0.265L0.549,-0.234L0.177,-0.234C0.181,-0.188 0.194,-0.154 0.217,-0.13C0.24,-0.106 0.27,-0.094 0.307,-0.094C0.336,-0.094 0.359,-0.101 0.378,-0.115C0.397,-0.128 0.41,-0.146 0.418,-0.168L0.539,-0.159C0.522,-0.106 0.494,-0.064 0.454,-0.034C0.414,-0.003 0.364,0.012 0.305,0.012ZM0.178,-0.32L0.421,-0.32C0.418,-0.361 0.405,-0.391 0.384,-0.41C0.362,-0.43 0.335,-0.44 0.302,-0.44C0.268,-0.44 0.241,-0.429 0.219,-0.409C0.198,-0.389 0.184,-0.359 0.178,-0.32Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
</g>
<g transform="matrix(460.901,0,0,460.901,4003.95,576.339)">
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
#!/bin/bash
# Function to read a variable from a specific env file
read_env_var() {
local file="$1"
local var="$2"
if [ -f "$file" ]; then
grep "^$var=" "$file" | cut -d '=' -f2- | tr -d '"' | tr -d "'"
fi
}
# 1. Get Production URL
# Priority: Argument > .env.production > .env
PROD_DB_URL="$1"
if [ -z "$PROD_DB_URL" ]; then
echo "Checking .env.production for DATABASE_URL..."
PROD_DB_URL=$(read_env_var ".env.production" "DATABASE_URL")
fi
if [ -z "$PROD_DB_URL" ]; then
echo "Checking .env for PROD_DATABASE_URL..."
PROD_DB_URL=$(read_env_var ".env" "PROD_DATABASE_URL")
fi
if [ -z "$PROD_DB_URL" ]; then
echo "Error: Could not find production database URL."
echo "Please provide it as an argument, or set DATABASE_URL in .env.production, or PROD_DATABASE_URL in .env"
echo "Usage: $0 <PROD_DATABASE_URL>"
exit 1
fi
# 2. Get Target URL
# Priority: .env.local > .env
TARGET_DB_URL=$(read_env_var ".env.local" "DATABASE_URL")
if [ -z "$TARGET_DB_URL" ]; then TARGET_DB_URL=$(read_env_var ".env" "DATABASE_URL"); fi
if [ -z "$TARGET_DB_URL" ]; then
echo "Error: Could not find target DATABASE_URL in .env.local or .env"
exit 1
fi
echo "Configuration:"
echo " Source: $PROD_DB_URL"
echo " Target: $TARGET_DB_URL"
echo
echo "⚠️ WARNING: This will OVERWRITE the target database at the above URL."
echo "This is a one-time migration script."
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 1
fi
echo "Cloning database..."
# Use local pg_dump and psql directly
# This assumes pg_dump and psql are installed on the host machine
pg_dump "$PROD_DB_URL" \
--clean --if-exists \
--no-owner --no-privileges \
--format=plain \
| psql "$TARGET_DB_URL"
if [ $? -eq 0 ]; then
echo "✅ Database cloned successfully!"
else
echo "❌ Database clone failed."
exit 1
fi
+32
View File
@@ -0,0 +1,32 @@
#!/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 "$@"
@@ -0,0 +1,61 @@
import "dotenv/config";
import bcrypt from "bcryptjs";
import { Pool } from "pg";
const DEMO_USER_ID = "a0000000-0000-4000-8000-000000000001";
const password = process.env.DEMO_ACCOUNT_PASSWORD?.trim();
const databaseUrl = process.env.DATABASE_URL?.trim();
if (!databaseUrl) {
throw new Error("DATABASE_URL is required");
}
if (!password || password.length < 12) {
throw new Error("DEMO_ACCOUNT_PASSWORD must be at least 12 characters");
}
const pool = new Pool({ connectionString: databaseUrl, ssl: false });
try {
const passwordHash = await bcrypt.hash(password, 12);
const client = await pool.connect();
try {
await client.query("BEGIN");
const userResult = await client.query(
`UPDATE "beenvoice_user"
SET "password" = $1, "updatedAt" = NOW()
WHERE "id" = $2`,
[passwordHash, DEMO_USER_ID],
);
const accountResult = await client.query(
`UPDATE "beenvoice_account"
SET "password" = $1, "updatedAt" = NOW()
WHERE "userId" = $2 AND "providerId" = 'credential'`,
[passwordHash, DEMO_USER_ID],
);
if (userResult.rowCount !== 1 || accountResult.rowCount !== 1) {
throw new Error(
"Demo account is missing. Apply database migrations before provisioning it.",
);
}
await client.query(`DELETE FROM "beenvoice_session" WHERE "userId" = $1`, [
DEMO_USER_ID,
]);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
console.log(
"Demo review account provisioned; previous sessions were invalidated.",
);
} finally {
await pool.end();
}
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." &>/dev/null && pwd)"
cd "${PROJECT_ROOT}"
echo "[setup-env] Project root: ${PROJECT_ROOT}"
ENV_EXAMPLE_FILE="${PROJECT_ROOT}/env.example"
ENV_FILE="${PROJECT_ROOT}/.env"
FORCE=${FORCE:-false}
if [[ ! -f "${ENV_EXAMPLE_FILE}" ]]; then
echo "[setup-env] ERROR: env.example not found at ${ENV_EXAMPLE_FILE}" >&2
exit 1
fi
if [[ -f "${ENV_FILE}" && "${FORCE}" != "true" ]]; then
echo "[setup-env] .env already exists. Set FORCE=true to overwrite. Skipping."
exit 0
fi
echo "[setup-env] Generating secrets for .env"
GEN_AUTH_SECRET=$(openssl rand -hex 32 2>/dev/null || cat /proc/sys/kernel/random/uuid)
GEN_DB_PASSWORD=$(openssl rand -hex 16 2>/dev/null || cat /proc/sys/kernel/random/uuid)
TMP_FILE=$(mktemp)
sed \
-e "s/^AUTH_SECRET=__GENERATE__/AUTH_SECRET=${GEN_AUTH_SECRET}/" \
-e "s/^POSTGRES_PASSWORD=__GENERATE__/POSTGRES_PASSWORD=${GEN_DB_PASSWORD}/" \
"${ENV_EXAMPLE_FILE}" > "${TMP_FILE}"
mv "${TMP_FILE}" "${ENV_FILE}"
echo "[setup-env] Wrote ${ENV_FILE} with generated AUTH_SECRET and POSTGRES_PASSWORD"
echo "[setup-env] You can edit ${ENV_FILE} to adjust PORT, RESEND_* and other values."
exit 0
#!/usr/bin/env bash
set -euo pipefail
# Resolve project root (directory containing this script's parent)
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." &>/dev/null && pwd)"
cd "${PROJECT_ROOT}"
echo "[setup-env] Project root: ${PROJECT_ROOT}"
ENV_EXAMPLE_FILE="${PROJECT_ROOT}/env.example"
ENV_FILE="${PROJECT_ROOT}/.env"
FORCE=${FORCE:-false}
if [[ ! -f "${ENV_EXAMPLE_FILE}" ]]; then
echo "[setup-env] ERROR: env.example not found at ${ENV_EXAMPLE_FILE}" >&2
exit 1
fi
if [[ -f "${ENV_FILE}" && "${FORCE}" != "true" ]]; then
echo "[setup-env] .env already exists. Set FORCE=true to overwrite. Skipping."
exit 0
fi
echo "[setup-env] Generating secrets for .env"
# Generate secrets
GEN_AUTH_SECRET=$(openssl rand -hex 32 2>/dev/null || cat /proc/sys/kernel/random/uuid)
GEN_DB_PASSWORD=$(openssl rand -hex 16 2>/dev/null || cat /proc/sys/kernel/random/uuid)
TMP_FILE=$(mktemp)
# Perform replacements
sed \
-e "s/^AUTH_SECRET=__GENERATE__/AUTH_SECRET=${GEN_AUTH_SECRET}/" \
-e "s/^POSTGRES_PASSWORD=__GENERATE__/POSTGRES_PASSWORD=${GEN_DB_PASSWORD}/" \
"${ENV_EXAMPLE_FILE}" > "${TMP_FILE}"
mv "${TMP_FILE}" "${ENV_FILE}"
echo "[setup-env] Wrote ${ENV_FILE} with generated AUTH_SECRET and POSTGRES_PASSWORD"
echo "[setup-env] You can edit ${ENV_FILE} to adjust PORT, RESEND_* and other values."
exit 0
@@ -0,0 +1,85 @@
/**
* Ensures every drizzle/*.sql migration file has a matching entry in meta/_journal.json.
* Run: bun scripts/verify-drizzle-journal.ts
*/
import { readdirSync, readFileSync } from "fs";
import path from "path";
const drizzleDir = path.resolve(import.meta.dir, "../drizzle");
const journalPath = path.join(drizzleDir, "meta/_journal.json");
const journal = JSON.parse(readFileSync(journalPath, "utf8")) as {
entries: Array<{ idx: number; tag: string; when: number }>;
};
const sqlTags = readdirSync(drizzleDir)
.filter((name) => /^\d+_.+\.sql$/.test(name))
.map((name) => name.replace(/\.sql$/, ""))
.sort();
const journalTags = journal.entries.map((entry) => entry.tag).sort();
const missingFromJournal = sqlTags.filter((tag) => !journalTags.includes(tag));
const missingSql = journalTags.filter((tag) => !sqlTags.includes(tag));
const idxSequence = journal.entries.map((entry) => entry.idx);
const expectedIdx = journal.entries.map((_, i) => i);
const badIdx = idxSequence.some((idx, i) => idx !== expectedIdx[i]);
// drizzle's migrator gates on the single highest `when` already recorded in
// the target DB — it doesn't check hashes per-migration. If `when` values
// ever go non-increasing (e.g. a migration got deleted/renumbered after
// being applied to some environment), later entries can silently be skipped
// forever even though drizzle reports success. Keep this strictly increasing.
//
// 0008 and 0011 are known pre-existing exceptions from before this check
// existed (see git log on those files) — both already guard every statement
// with IF NOT EXISTS specifically because of this, so a skip is harmless.
// Don't add new exceptions here; fix the timestamp instead.
const KNOWN_NON_MONOTONIC_TAGS = new Set([
"0008_payments_recurring_public_links",
"0011_time_entry_invoice_id",
]);
const nonMonotonic = journal.entries.some(
(entry, i) =>
i > 0 &&
entry.when <= journal.entries[i - 1]!.when &&
!KNOWN_NON_MONOTONIC_TAGS.has(entry.tag),
);
let failed = false;
if (missingFromJournal.length > 0) {
console.error("[verify-drizzle-journal] SQL files missing from journal:");
for (const tag of missingFromJournal) console.error(` - ${tag}`);
failed = true;
}
if (missingSql.length > 0) {
console.error("[verify-drizzle-journal] Journal entries without SQL files:");
for (const tag of missingSql) console.error(` - ${tag}`);
failed = true;
}
if (badIdx) {
console.error("[verify-drizzle-journal] Journal idx values are not sequential from 0");
failed = true;
}
if (nonMonotonic) {
console.error(
"[verify-drizzle-journal] Journal `when` timestamps are not strictly increasing. " +
"drizzle-orm's migrator only compares against the single highest `when` already " +
"applied in the target DB, so a lower or equal value here can cause migrations to " +
"be silently skipped on databases that already ran a later timestamp.",
);
failed = true;
}
if (failed) {
process.exit(1);
}
console.log(
`[verify-drizzle-journal] OK — ${sqlTags.length} migrations match journal entries`,
);
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { MarketingProviders } from "~/components/providers/marketing-providers";
import { brand } from "~/lib/branding";
export const metadata: Metadata = {
title: {
template: `%s | ${brand.name}`,
default: `Legal | ${brand.name}`,
},
};
export default function LegalLayout({
children,
}: {
children: React.ReactNode;
}) {
return <MarketingProviders>{children}</MarketingProviders>;
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { PrivacyPolicyContent } from "~/components/legal/privacy-policy-content";
import { LegalPageShell } from "~/components/legal/legal-page-shell";
import { brand } from "~/lib/branding";
export const metadata: Metadata = {
title: `Privacy Policy | ${brand.name}`,
description: `How ${brand.name} collects, uses, and protects your data.`,
};
export default function PrivacyPolicyPage() {
return (
<LegalPageShell
title="Privacy Policy"
description={`How ${brand.name} collects, uses, and protects your data across the web and mobile apps.`}
>
<PrivacyPolicyContent />
</LegalPageShell>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { LegalPageShell } from "~/components/legal/legal-page-shell";
import { TermsOfServiceContent } from "~/components/legal/terms-of-service-content";
import { brand } from "~/lib/branding";
export const metadata: Metadata = {
title: `Terms of Service | ${brand.name}`,
description: `Terms governing your use of the ${brand.name} platform.`,
};
export default function TermsOfServicePage() {
return (
<LegalPageShell
title="Terms of Service"
description={`The rules for using ${brand.name} on the web and mobile apps.`}
>
<TermsOfServiceContent />
</LegalPageShell>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { MarketingProviders } from "~/components/providers/marketing-providers";
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return <MarketingProviders>{children}</MarketingProviders>;
}
+14
View File
@@ -0,0 +1,14 @@
import { LandingPage } from "~/components/marketing/landing-page";
import { env } from "~/env";
export const dynamic = "force-dynamic";
export default function HomePage() {
const allowRegistration = env.DISABLE_SIGNUPS !== true;
return (
<main className="min-h-screen">
<LandingPage allowRegistration={allowRegistration} />
</main>
);
}
@@ -0,0 +1,4 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "~/lib/auth";
export const { GET, POST } = toNextJsHandler(auth);
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { env } from "~/env";
export function GET() {
return NextResponse.json({
authentik: env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true,
signupsDisabled: env.DISABLE_SIGNUPS === true,
});
}
@@ -0,0 +1,73 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
import { sendPasswordResetForUser } from "~/lib/password-reset";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
export async function POST(request: NextRequest) {
try {
const { email } = (await request.json()) as { email: string };
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
const normalizedEmail = email.toLowerCase().trim();
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:forgot"), {
windowMs: 60 * 60 * 1000,
max: 10,
});
if (ipRateLimit) return ipRateLimit;
const emailRateLimit = requireRateLimit(
rateLimitKey(request, "auth:forgot-email", normalizedEmail),
{
windowMs: 60 * 60 * 1000,
max: 3,
},
);
if (emailRateLimit) return emailRateLimit;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(normalizedEmail)) {
return NextResponse.json(
{ error: "Invalid email format" },
{ status: 400 },
);
}
const user = await db.query.users.findFirst({
where: eq(users.email, normalizedEmail),
columns: { id: true },
});
if (!user) {
return NextResponse.json(
{
success: true,
message:
"If an account with that email exists, password reset instructions have been sent.",
},
{ status: 200 },
);
}
await sendPasswordResetForUser(user.id);
return NextResponse.json(
{
success: true,
message:
"If an account with that email exists, password reset instructions have been sent.",
},
{ status: 200 },
);
} catch (error) {
console.error("Password reset error:", error);
return NextResponse.json(
{ error: "An error occurred while processing your request" },
{ status: 500 },
);
}
}
+196
View File
@@ -0,0 +1,196 @@
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "~/lib/auth";
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
import { resolveNewUserRole } from "~/lib/first-admin";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { env } from "~/env";
import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema";
const registerSchema = z
.object({
firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().trim().min(1, "Last name is required"),
name: z.string().trim().optional(),
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
})
.transform((data) => {
if (data.name?.length) {
const parts = data.name.trim().split(/\s+/);
const firstName = parts[0] ?? "";
const lastName = parts.slice(1).join(" ") || firstName;
return {
firstName,
lastName,
email: data.email,
password: data.password,
};
}
return {
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
password: data.password,
};
});
const fieldLabels: Record<string, string> = {
firstName: "First name",
lastName: "Last name",
name: "Name",
email: "Email address",
password: "Password",
};
function formatRegisterError(error: z.ZodError): string {
const issue = error.issues[0] ?? error.errors[0];
if (!issue) return "Please check the registration form";
const field = issue.path[0];
const label =
typeof field === "string" ? (fieldLabels[field] ?? field) : "Field";
if (
issue.code === "invalid_type" &&
"received" in issue &&
issue.received === "undefined"
) {
return `${label} is required`;
}
if (issue.message && issue.message !== "Required") {
return issue.message;
}
return `${label} is required`;
}
export async function POST(request: NextRequest) {
try {
const rateLimit = requireRateLimit(rateLimitKey(request, "auth:register"), {
windowMs: 60 * 60 * 1000,
max: 5,
});
if (rateLimit) return rateLimit;
if (env.DISABLE_SIGNUPS === true) {
return NextResponse.json(
{ error: "New account registration is currently disabled" },
{ status: 403 },
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Invalid request body. Please try again." },
{ status: 400 },
);
}
if (!body || typeof body !== "object") {
return NextResponse.json(
{ error: "Registration details are required" },
{ status: 400 },
);
}
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: formatRegisterError(parsed.error) },
{ status: 400 },
);
}
const { firstName, lastName, email, password } = parsed.data;
const normalizedEmail = email.toLowerCase();
const emailRateLimit = requireRateLimit(
rateLimitKey(request, "auth:register-email", normalizedEmail),
{
windowMs: 60 * 60 * 1000,
max: 3,
},
);
if (emailRateLimit) return emailRateLimit;
const existingUser = await db.query.users.findFirst({
where: eq(users.email, normalizedEmail),
});
if (existingUser) {
return NextResponse.json(
{ error: "Registration failed. Please check the form or sign in." },
{ status: 400 },
);
}
const hashedPassword = await bcrypt.hash(password, 12);
await db.transaction(async (tx) => {
const role = await resolveNewUserRole(tx);
const [user] = await tx
.insert(users)
.values({
name: `${firstName} ${lastName}`,
email: normalizedEmail,
password: hashedPassword,
role,
})
.returning({ id: users.id });
if (!user) {
throw new Error("Failed to create user");
}
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
});
try {
await auth.api.signInEmail({
body: {
email: normalizedEmail,
password,
},
headers: request.headers,
});
} catch (signInError) {
console.error("Post-register sign-in failed:", signInError);
return NextResponse.json(
{ message: "User created successfully", signInRequired: true },
{ status: 201 },
);
}
return NextResponse.json(
{ message: "User created successfully" },
{ status: 201 },
);
} catch (error) {
console.error("Registration error:", error);
const databaseSetupError = getDatabaseSetupErrorMessage(error);
if (databaseSetupError) {
return NextResponse.json({ error: databaseSetupError }, { status: 503 });
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,121 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { hashPasswordResetToken } from "~/lib/reset-token";
import { revokeUserSessions } from "~/lib/session-security";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema";
export async function POST(request: NextRequest) {
try {
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:reset"), {
windowMs: 60 * 1000,
max: 10,
});
if (ipRateLimit) return ipRateLimit;
const { token, password } = (await request.json()) as {
token: string;
password: string;
};
if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Token is required" }, { status: 400 });
}
if (!password || typeof password !== "string") {
return NextResponse.json(
{ error: "Password is required" },
{ status: 400 },
);
}
if (password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters long" },
{ status: 400 },
);
}
const tokenRateLimit = requireRateLimit(
rateLimitKey(request, "auth:reset-token", token),
{
windowMs: 60 * 60 * 1000,
max: 5,
},
);
if (tokenRateLimit) return tokenRateLimit;
const tokenHash = hashPasswordResetToken(token);
// Find user with valid reset token that hasn't expired
const user = await db.query.users.findFirst({
where: and(
eq(users.resetToken, tokenHash),
gt(users.resetTokenExpiry, new Date()),
),
});
if (!user) {
return NextResponse.json(
{ error: "Invalid or expired token" },
{ status: 400 },
);
}
// Hash the new password
const hashedPassword = await bcrypt.hash(password, 12);
await db.transaction(async (tx) => {
await tx
.update(users)
.set({
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null,
})
.where(eq(users.id, user.id));
const credentialAccount = await tx.query.accounts.findFirst({
where: and(
eq(accounts.userId, user.id),
eq(accounts.providerId, "credential"),
),
});
if (credentialAccount) {
await tx
.update(accounts)
.set({
password: hashedPassword,
updatedAt: new Date(),
})
.where(eq(accounts.id, credentialAccount.id));
} else {
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
}
});
await revokeUserSessions(user.id);
return NextResponse.json(
{
success: true,
message: "Password has been reset successfully",
},
{ status: 200 },
);
} catch (error) {
console.error("Password reset error:", error);
return NextResponse.json(
{ error: "An error occurred while resetting your password" },
{ status: 500 },
);
}
}
@@ -0,0 +1,56 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm";
import { hashPasswordResetToken } from "~/lib/reset-token";
import { rateLimitKey, requireRateLimit } from "~/lib/rate-limit";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
export async function POST(request: NextRequest) {
try {
const ipRateLimit = requireRateLimit(rateLimitKey(request, "auth:validate-reset"), {
windowMs: 60 * 1000,
max: 20,
});
if (ipRateLimit) return ipRateLimit;
const { token } = (await request.json()) as { token: string };
if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Token is required" }, { status: 400 });
}
const tokenRateLimit = requireRateLimit(
rateLimitKey(request, "auth:validate-reset-token", token),
{
windowMs: 60 * 60 * 1000,
max: 5,
},
);
if (tokenRateLimit) return tokenRateLimit;
const tokenHash = hashPasswordResetToken(token);
// Find user with valid reset token that hasn't expired
const user = await db.query.users.findFirst({
where: and(
eq(users.resetToken, tokenHash),
gt(users.resetTokenExpiry, new Date()),
),
});
if (!user) {
return NextResponse.json(
{ error: "Invalid or expired token" },
{ status: 400 },
);
}
return NextResponse.json({ valid: true }, { status: 200 });
} catch (error) {
console.error("Token validation error:", error);
return NextResponse.json(
{ error: "An error occurred while validating the token" },
{ status: 500 },
);
}
}
@@ -0,0 +1,78 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { getObject } from "~/lib/object-storage";
import { db } from "~/server/db";
import { businesses } from "~/server/db/schema";
export const runtime = "nodejs";
const RASTERIZABLE_MIME_TYPES = new Set(["image/svg+xml", "image/webp"]);
// Intentionally unauthenticated: a business logo must be viewable on public,
// token-based invoice pages without a session. Business IDs are random
// UUIDs, so this only serves images to callers who already know the ID.
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ businessId: string }> },
) {
const { businessId } = await params;
const business = await db.query.businesses.findFirst({
where: eq(businesses.id, businessId),
columns: { logoStorageKey: true, logoMimeType: true },
});
if (!business?.logoStorageKey || !business.logoMimeType) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
// generation requests a rasterized copy of SVG/WebP logos via this param.
const wantsPng =
new URL(req.url).searchParams.get("format") === "png" &&
RASTERIZABLE_MIME_TYPES.has(business.logoMimeType);
try {
const body = await getObject(business.logoStorageKey);
if (wantsPng) {
const { default: sharp } = await import("sharp");
const isSvg = business.logoMimeType === "image/svg+xml";
// SVG is vector: rasterize at a high density so the PNG stays crisp at
// the size it's actually displayed (PDF header, up to ~2.2in wide).
// withoutEnlargement only makes sense for the WebP (already-raster)
// case — for SVG it would cap us at whatever tiny canvas the source's
// intrinsic viewBox implies, even though the vector has no such limit.
const png = await sharp(body, isSvg ? { density: 600 } : undefined)
.resize({
width: 1024,
height: 1024,
fit: "inside",
withoutEnlargement: !isSvg,
})
.png()
.toBuffer();
return new NextResponse(new Uint8Array(png), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=300, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
});
}
return new NextResponse(new Uint8Array(body), {
headers: {
"Content-Type": business.logoMimeType,
"Cache-Control": "public, max-age=300, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
console.error("[business-logo] Failed to serve logo", {
backendError: error,
businessId,
wantsPng,
});
return NextResponse.json({ error: "Logo not found" }, { status: 404 });
}
}
@@ -0,0 +1,23 @@
import { type NextRequest, NextResponse } from "next/server";
import { env } from "~/env";
import { db } from "~/server/db";
import { generateDueRecurringInvoices } from "~/server/api/routers/recurring-invoices";
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
const secret = env.CRON_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "Cron secret is not configured" },
{ status: 500 },
);
}
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const generated = await generateDueRecurringInvoices(db);
return NextResponse.json({ generated });
}
@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "~/server/db";
import { invoices, platformSettings } from "~/server/db/schema";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
export const runtime = "nodejs";
export async function GET(
request: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const { token } = await params;
const invoice = await db.query.invoices.findFirst({
where: eq(invoices.publicToken, token),
with: {
client: true,
// Explicit allowlist: token-based public route — never fetch
// secret fields (resendApiKey, resendDomain) for an unauthenticated request.
business: {
columns: {
id: true,
name: true,
nickname: true,
email: true,
phone: true,
addressLine1: true,
addressLine2: true,
city: true,
state: true,
postalCode: true,
country: true,
website: true,
taxId: true,
logoStorageKey: true,
logoMimeType: true,
hideNameWithLogo: true,
},
},
items: {
orderBy: (i, { asc }) => [
asc(i.date),
asc(i.position),
asc(i.createdAt),
],
},
},
});
if (!invoice) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) {
return NextResponse.json({ error: "This link has expired" }, { status: 410 });
}
const settings = await db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
});
const pdfBlob = await generateInvoicePDFBlob(
invoice,
{
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFontFamily: settings?.pdfFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
| "sans"
| "serif"
| "mono"
| undefined,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
},
{ logoBaseUrl: new URL(request.url).origin },
);
const buffer = await pdfBlob.arrayBuffer();
const filename = `invoice-${invoice.invoiceNumber}.pdf`;
return new Response(buffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${filename}"`,
"Cache-Control": "private, no-store",
},
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { getOptionalServerSession } from "~/lib/auth-server";
import { getObject } from "~/lib/object-storage";
import { db } from "~/server/db";
import { expenseReceipts } from "~/server/db/schema";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getOptionalServerSession(req.headers);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const receipt = await db.query.expenseReceipts.findFirst({
where: eq(expenseReceipts.id, id),
with: { expense: true },
});
if (receipt?.expense.createdById !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
try {
const body = await getObject(receipt.storageKey);
return new NextResponse(new Uint8Array(body), {
headers: {
"Content-Type": receipt.mimeType,
"Content-Disposition": `inline; filename="${encodeURIComponent(receipt.originalFilename)}"`,
"Cache-Control": "private, max-age=3600",
},
});
} catch {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
}
+34
View File
@@ -0,0 +1,34 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { type NextRequest } from "next/server";
import { env } from "~/env";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a HTTP request (e.g. when you make requests from Client Components).
*/
const createContext = async (req: NextRequest) => {
return createTRPCContext({
headers: req.headers,
});
};
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext(req),
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
export { handler as GET, handler as POST };
@@ -0,0 +1,368 @@
"use client";
import { useState, Suspense } from "react";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalAgreementNotice } from "~/components/legal/legal-links";
import {
Mail,
ArrowRight,
ArrowLeft,
Shield,
Clock,
CheckCircle,
} from "lucide-react";
function ForgotPasswordForm() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
const response = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
const data = (await response.json()) as { error?: string };
if (response.ok) {
setSent(true);
toast.success("Password reset instructions sent to your email");
} else {
toast.error(data.error ?? "Failed to send reset email");
}
} catch {
toast.error("An error occurred. Please try again.");
} finally {
setLoading(false);
}
}
if (sent) {
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl">
Check your
<span className="text-primary"> email inbox</span>
</h1>
<p className="text-muted-foreground text-lg">
We&apos;ve sent password reset instructions to your email
address. Follow the link to create a new password.
</p>
</div>
</div>
<div className="grid gap-4">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Mail className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Check your inbox</h3>
<p className="text-muted-foreground text-sm">
Look for an email from beenvoice with reset instructions
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Clock className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Link expires soon</h3>
<p className="text-muted-foreground text-sm">
The reset link is valid for 24 hours only
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Shield className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Secure Process</h3>
<p className="text-muted-foreground text-sm">
Your account security is our top priority
</p>
</div>
</div>
</div>
<div className="bg-primary/5 flex items-center space-x-4 rounded-lg p-4">
<CheckCircle className="text-primary h-8 w-8" />
<div>
<p className="font-semibold">Email sent successfully</p>
<p className="text-muted-foreground text-sm">
Follow the instructions in your email to reset your
password
</p>
</div>
</div>
</div>
</div>
{/* Success Message */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center">
<div className="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<CheckCircle className="text-primary h-8 w-8" />
</div>
<h1 className="text-2xl font-bold">Check your email</h1>
<p className="text-muted-foreground">
We&apos;ve sent password reset instructions to{" "}
<span className="font-medium">{email}</span>
</p>
</div>
<div className="bg-muted/50 space-y-3 rounded-lg p-4">
<h3 className="font-semibold">What&apos;s next?</h3>
<ul className="space-y-2 text-sm">
<li className="flex items-start space-x-2">
<span className="text-primary">1.</span>
<span>Check your email inbox (and spam folder)</span>
</li>
<li className="flex items-start space-x-2">
<span className="text-primary">2.</span>
<span>Click the reset link in the email</span>
</li>
<li className="flex items-start space-x-2">
<span className="text-primary">3.</span>
<span>Create a new secure password</span>
</li>
</ul>
</div>
<div className="space-y-3">
<Button
onClick={() => {
setSent(false);
setEmail("");
}}
variant="outline"
className="h-11 w-full"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Try a different email
</Button>
<a href="/auth/signin">
<Button className="h-11 w-full">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Sign In
</Button>
</a>
</div>
<div className="text-muted-foreground text-center text-xs">
Didn&apos;t receive the email? Check your spam folder or{" "}
<button
onClick={() => {
setSent(false);
toast.info("You can try sending the email again");
}}
className="text-primary hover:underline"
>
try again
</button>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl">
Forgot your
<span className="text-primary"> password?</span>
</h1>
<p className="text-muted-foreground text-lg">
No worries! Enter your email address and we&apos;ll send you
instructions to reset your password.
</p>
</div>
</div>
<div className="grid gap-4">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Mail className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Email Instructions</h3>
<p className="text-muted-foreground text-sm">
We&apos;ll send a secure link to your email address
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Clock className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Quick Process</h3>
<p className="text-muted-foreground text-sm">
Reset your password in just a few clicks
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Shield className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Secure & Safe</h3>
<p className="text-muted-foreground text-sm">
Your account security is our top priority
</p>
</div>
</div>
</div>
</div>
</div>
{/* Forgot Password Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="text-2xl font-bold">Forgot Password</h1>
<p className="text-muted-foreground">
Enter your email and we&apos;ll send you reset instructions
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="h-11 pl-10"
placeholder="Enter your email address"
/>
</div>
</div>
<Button
type="submit"
className="h-11 w-full"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Sending instructions...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Send Reset Instructions</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-start space-x-3">
<Mail className="text-primary mt-0.5 h-4 w-4 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium">Check your spam folder</p>
<p className="text-muted-foreground text-sm">
Sometimes our emails end up in spam or promotions folders
</p>
</div>
</div>
</div>
<div className="text-center">
<a
href="/auth/signin"
className="text-primary inline-flex items-center space-x-1 text-sm font-medium hover:underline"
>
<ArrowLeft className="h-3 w-3" />
<span>Back to Sign In</span>
</a>
</div>
<div className="text-muted-foreground text-center text-xs">
Remember your password?{" "}
<a
href="/auth/signin"
className="text-primary font-medium hover:underline"
>
Sign in instead
</a>
</div>
<LegalAgreementNotice
action="using our service"
className="leading-relaxed"
/>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function ForgotPasswordPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ForgotPasswordForm />
</Suspense>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { MarketingProviders } from "~/components/providers/marketing-providers";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return <MarketingProviders>{children}</MarketingProviders>;
}
+6
View File
@@ -0,0 +1,6 @@
import { env } from "~/env";
import { RegisterForm } from "./register-form";
export default function RegisterPage() {
return <RegisterForm signupsDisabled={env.DISABLE_SIGNUPS === true} />;
}
@@ -0,0 +1,234 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, Lock, Mail, User, UserX } from "lucide-react";
import {
AuthCard,
AuthCardHeader,
AuthPageShell,
} from "~/components/auth/auth-page-shell";
import { LegalAgreementNotice } from "~/components/legal/legal-links";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
function formatAuthError(message: string | undefined, fallback: string): string {
if (!message || message === "Required") {
return fallback;
}
return message;
}
interface RegisterFormProps {
signupsDisabled?: boolean;
}
export function RegisterForm({ signupsDisabled = false }: RegisterFormProps) {
const router = useRouter();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
const trimmedFirstName = firstName.trim();
const trimmedLastName = lastName.trim();
const trimmedEmail = email.trim();
if (!trimmedFirstName || !trimmedLastName || !trimmedEmail) {
toast.error("Please enter your first name, last name, and email.");
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters.");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: trimmedFirstName,
lastName: trimmedLastName,
email: trimmedEmail,
password,
}),
});
let data: { error?: string; signInRequired?: boolean } = {};
try {
data = (await res.json()) as typeof data;
} catch {
toast.error("Registration failed. Please try again.");
return;
}
if (!res.ok) {
toast.error(
formatAuthError(data.error, "Registration failed. Please check the form."),
);
return;
}
if (data.signInRequired) {
toast.success("Account created! Please sign in.");
router.push("/auth/signin");
return;
}
toast.success("Account created successfully!");
router.push("/dashboard");
router.refresh();
} catch {
toast.error("Registration failed. Please try again.");
} finally {
setLoading(false);
}
}
if (signupsDisabled) {
return (
<AuthPageShell>
<AuthCard>
<AuthCardHeader
title="Registration closed"
description="New account sign-ups are not available right now"
/>
<div className="bg-muted/50 text-muted-foreground mb-6 flex gap-3 rounded-xl border px-4 py-3 text-sm">
<UserX className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<p>
This workspace is not accepting new registrations. If you already
have an account, sign in below. Contact your administrator if you
need access.
</p>
</div>
<Button asChild className="h-11 w-full">
<Link href="/auth/signin">
Sign in to your account
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</AuthCard>
</AuthPageShell>
);
}
return (
<AuthPageShell>
<AuthCard>
<AuthCardHeader
title="Create your account"
description="Get started with your workspace"
/>
<form onSubmit={handleRegister} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="firstName">First name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="firstName"
name="firstName"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
autoFocus
autoComplete="given-name"
className="h-11 pl-10"
placeholder="John"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="lastName"
name="lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
autoComplete="family-name"
className="h-11 pl-10"
placeholder="Doe"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="h-11 pl-10"
placeholder="you@example.com"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="h-11 pl-10"
placeholder="••••••••"
/>
</div>
<p className="text-muted-foreground text-xs">At least 8 characters</p>
</div>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{loading ? "Creating account…" : "Create account"}
{!loading && <ArrowRight className="ml-2 h-4 w-4" />}
</Button>
</form>
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{" "}
<Link
href="/auth/signin"
className="text-foreground font-medium hover:underline"
>
Sign in
</Link>
</p>
<LegalAgreementNotice action="creating an account" className="mt-5" />
</AuthCard>
</AuthPageShell>
);
}
@@ -0,0 +1,446 @@
"use client";
import { useState, Suspense, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalAgreementNotice } from "~/components/legal/legal-links";
import {
Lock,
ArrowRight,
ArrowLeft,
CheckCircle,
Shield,
Eye,
EyeOff,
} from "lucide-react";
function ResetPasswordForm() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [tokenValid, setTokenValid] = useState<boolean | null>(() =>
token ? null : false,
);
useEffect(() => {
if (!token) {
return;
}
// Validate token on page load
const validateToken = async () => {
try {
const response = await fetch("/api/auth/validate-reset-token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
});
if (response.ok) {
setTokenValid(true);
} else {
setTokenValid(false);
}
} catch {
setTokenValid(false);
}
};
void validateToken();
}, [token]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!token) {
toast.error("Invalid reset token");
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters long");
return;
}
if (password !== confirmPassword) {
toast.error("Passwords do not match");
return;
}
setLoading(true);
try {
const response = await fetch("/api/auth/reset-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token, password }),
});
const data = (await response.json()) as { error?: string };
if (response.ok) {
setSuccess(true);
toast.success("Password reset successfully!");
} else {
toast.error(data.error ?? "Failed to reset password");
}
} catch {
toast.error("An error occurred. Please try again.");
} finally {
setLoading(false);
}
}
if (tokenValid === null) {
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<div className="text-center">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"></div>
<p className="text-muted-foreground mt-4">
Validating reset token...
</p>
</div>
</div>
);
}
if (tokenValid === false) {
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl">
Invalid or
<span className="text-destructive"> expired link</span>
</h1>
<p className="text-muted-foreground text-lg">
This password reset link is either invalid or has expired.
Please request a new password reset.
</p>
</div>
</div>
<div className="grid gap-4">
<div className="flex items-start space-x-4">
<div className="bg-destructive/10 rounded-lg p-2">
<Shield className="text-destructive h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Security First</h3>
<p className="text-muted-foreground text-sm">
Reset links expire after 24 hours for your security
</p>
</div>
</div>
</div>
</div>
</div>
{/* Error Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center">
<div className="bg-destructive/10 justify-content mx-auto mb-4 flex h-16 w-16 items-center rounded-full">
<Shield className="text-destructive mx-auto h-8 w-8" />
</div>
<h1 className="text-2xl font-bold">Link Expired</h1>
<p className="text-muted-foreground">
This password reset link is no longer valid
</p>
</div>
<div className="space-y-3">
<a href="/auth/forgot-password">
<Button className="h-11 w-full">
Request New Reset Link
</Button>
</a>
<a href="/auth/signin">
<Button variant="outline" className="h-11 w-full">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Sign In
</Button>
</a>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
if (success) {
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl">
Password
<span className="text-primary"> reset complete</span>
</h1>
<p className="text-muted-foreground text-lg">
Your password has been successfully reset. You can now
sign in with your new password.
</p>
</div>
</div>
<div className="bg-primary/5 rounded-lg p-4">
<div className="flex items-center space-x-3">
<CheckCircle className="text-primary h-6 w-6" />
<div>
<p className="font-semibold">Security Updated</p>
<p className="text-muted-foreground text-sm">
Your account is now secured with your new password
</p>
</div>
</div>
</div>
</div>
</div>
{/* Success Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center">
<div className="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<CheckCircle className="text-primary h-8 w-8" />
</div>
<h1 className="text-2xl font-bold">
Password Reset Complete
</h1>
<p className="text-muted-foreground">
Your password has been successfully updated
</p>
</div>
<div className="space-y-3">
<a href="/auth/signin">
<Button className="h-11 w-full">
<ArrowRight className="mr-2 h-4 w-4" />
Sign In Now
</Button>
</a>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl">
Create your
<span className="text-primary"> new password</span>
</h1>
<p className="text-muted-foreground text-lg">
Choose a strong password to secure your beenvoice account.
Make sure it&apos;s something you&apos;ll remember.
</p>
</div>
</div>
<div className="grid gap-4">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Shield className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Secure Password</h3>
<p className="text-muted-foreground text-sm">
Use at least 8 characters with a mix of letters and
numbers
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Lock className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Account Safety</h3>
<p className="text-muted-foreground text-sm">
Your new password will immediately secure your account
</p>
</div>
</div>
</div>
</div>
</div>
{/* Reset Password Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="text-2xl font-bold">Reset Password</h1>
<p className="text-muted-foreground">
Enter your new password below
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="h-11 pr-10 pl-10"
placeholder="Enter new password"
minLength={8}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 z-10 -translate-y-1/2"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<p className="text-muted-foreground text-xs">
Must be at least 8 characters long
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="h-11 pr-10 pl-10"
placeholder="Confirm new password"
/>
<button
type="button"
onClick={() =>
setShowConfirmPassword(!showConfirmPassword)
}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 z-10 -translate-y-1/2"
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<Button
type="submit"
className="h-11 w-full"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Updating password...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Update Password</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
<div className="text-center">
<a
href="/auth/signin"
className="text-primary inline-flex items-center space-x-1 text-sm font-medium hover:underline"
>
<ArrowLeft className="h-3 w-3" />
<span>Back to Sign In</span>
</a>
</div>
<LegalAgreementNotice
action="resetting your password"
className="leading-relaxed"
/>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ResetPasswordForm />
</Suspense>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { Suspense } from "react";
import { env } from "~/env";
import { SignInForm } from "./signin-form";
export default function SignInPage() {
return (
<Suspense
fallback={
<div className="bg-dashboard text-muted-foreground flex min-h-screen items-center justify-center text-sm">
Loading
</div>
}
>
<SignInForm allowRegistration={env.DISABLE_SIGNUPS !== true} />
</Suspense>
);
}
@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowRight, Lock, Mail, Shield } from "lucide-react";
import {
AuthCard,
AuthCardHeader,
AuthPageShell,
} from "~/components/auth/auth-page-shell";
import { LegalAgreementNotice } from "~/components/legal/legal-links";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { env } from "~/env";
import { authClient } from "~/lib/auth-client";
import { safeCallbackPath } from "~/lib/safe-callback-url";
import { toast } from "sonner";
interface SignInFormProps {
allowRegistration: boolean;
}
export function SignInForm({ allowRegistration }: SignInFormProps) {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = safeCallbackPath(searchParams.get("callbackUrl"));
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const { error } = await authClient.signIn.email({ email, password });
setLoading(false);
if (error) {
const message = error.message?.toLowerCase() ?? "";
const rateLimited =
error.status === 429 ||
message.includes("too many") ||
message.includes("rate limit");
toast.error(
rateLimited
? "Too many sign-in attempts. Please wait a moment and try again."
: error.message && error.message !== "Required"
? error.message
: "Invalid email or password",
);
return;
}
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
async function handleSocialSignIn() {
setLoading(true);
try {
await authClient.signIn.oauth2({
providerId: "authentik",
callbackURL: callbackUrl,
});
} catch (error) {
console.error("[SSO Error]", error);
setLoading(false);
}
}
return (
<AuthPageShell>
<AuthCard>
<AuthCardHeader
title="Welcome back"
description="Sign in to your workspace"
/>
{!allowRegistration && (
<p className="bg-muted/50 text-muted-foreground mb-5 rounded-xl border px-3 py-2.5 text-sm">
New account registration is currently disabled.
</p>
)}
{authentikEnabled && (
<div className="mb-5 space-y-4">
<Button
variant="outline"
type="button"
className="h-11 w-full"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="border-border/50 w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background/80 text-muted-foreground px-2">
or
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
autoComplete="email"
className="h-11 pl-10"
placeholder="you@example.com"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="password">Password</Label>
<Link
href="/auth/forgot-password"
className="text-muted-foreground text-xs hover:text-foreground hover:underline"
>
Forgot password?
</Link>
</div>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="h-11 pl-10"
placeholder="••••••••"
/>
</div>
</div>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{loading ? "Signing in…" : "Sign in"}
{!loading && <ArrowRight className="ml-2 h-4 w-4" />}
</Button>
</form>
{allowRegistration && (
<p className="text-muted-foreground mt-6 text-center text-sm">
Don&apos;t have an account?{" "}
<Link
href="/auth/register"
className="text-foreground font-medium hover:underline"
>
Create account
</Link>
</p>
)}
<LegalAgreementNotice action="signing in" className="mt-5" />
</AuthCard>
</AuthPageShell>
);
}

Some files were not shown because too many files have changed in this diff Show More