Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'
git-subtree-dir: apps/web git-subtree-mainline:068a51b46bgit-subtree-split:1e7174fa60
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "beenvoice-dev",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeArgs": ["dev"],
|
||||
"port": 3000,
|
||||
"autoPort": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,280 @@
|
||||

|
||||
|
||||
# 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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 (~50–100 MB RAM vs MinIO). API :3900.
|
||||
garage:
|
||||
image: dxflrs/garage:v2.3.0
|
||||
environment:
|
||||
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK3515373e4c851ebaad366558}
|
||||
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34}
|
||||
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-beenvoice-receipts}
|
||||
configs:
|
||||
- source: garage_config
|
||||
target: /etc/garage.toml
|
||||
volumes:
|
||||
- beenvoice_garage_meta:/var/lib/garage/meta
|
||||
- beenvoice_garage_data:/var/lib/garage/data
|
||||
command: ["/garage", "server", "--single-node", "--default-bucket"]
|
||||
ports:
|
||||
- "${GARAGE_API_PORT:-3900}:3900"
|
||||
healthcheck:
|
||||
test: ["CMD", "/garage", "status"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 15
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
beenvoice_pg_data:
|
||||
beenvoice_garage_meta:
|
||||
beenvoice_garage_data:
|
||||
|
||||
configs:
|
||||
garage_config:
|
||||
content: |
|
||||
metadata_dir = "/var/lib/garage/meta"
|
||||
data_dir = "/var/lib/garage/data"
|
||||
db_engine = "sqlite"
|
||||
replication_factor = 1
|
||||
|
||||
rpc_bind_addr = "[::]:3901"
|
||||
rpc_public_addr = "garage:3901"
|
||||
rpc_secret = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
|
||||
|
||||
[s3_api]
|
||||
s3_region = "garage"
|
||||
api_bind_addr = "[::]:3900"
|
||||
root_domain = ".s3.garage"
|
||||
|
||||
[s3_web]
|
||||
bind_addr = "[::]:3902"
|
||||
root_domain = ".web.garage"
|
||||
index = "index.html"
|
||||
|
||||
[admin]
|
||||
api_bind_addr = "[::]:3903"
|
||||
admin_token = "beenvoice_garage_admin_token_change_me_in_production"
|
||||
metrics_token = "beenvoice_garage_metrics_token_change_me_in_production"
|
||||
@@ -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 (0000–0014+)
|
||||
```
|
||||
|
||||
## 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`
|
||||
@@ -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 (~50–100 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.
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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.
Binary file not shown.
Binary file not shown.
Executable
+71
@@ -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
|
||||
Executable
+32
@@ -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();
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||
|
||||
export default function MarketingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <MarketingProviders>{children}</MarketingProviders>;
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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'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'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'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'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'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <MarketingProviders>{children}</MarketingProviders>;
|
||||
}
|
||||
@@ -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's something you'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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
Reference in New Issue
Block a user