Fix Docker signup, refresh docs, and improve blank invoice flow.

Parse DISABLE_SIGNUPS and related env booleans correctly for Compose string values, and derive auth trustedOrigins from BETTER_AUTH_URL. Rewrite README and architecture docs with the git.soconnor.dev remote and accurate deployment guidance. Allow zero-line-item draft invoices with validation when sending email.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 23:45:57 -04:00
co-authored by Cursor
parent 480c50981d
commit 463b1f503e
12 changed files with 323 additions and 394 deletions
+7 -1
View File
@@ -1,7 +1,13 @@
"use client";
import { Suspense } from "react";
import InvoiceForm from "~/components/forms/invoice-form";
export default function NewInvoicePage() {
return <InvoiceForm />;
return (
<Suspense fallback={null}>
<InvoiceForm />
</Suspense>
);
}
+7 -1
View File
@@ -3,7 +3,7 @@ import { Suspense } from "react";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { PageHeader } from "~/components/layout/page-header";
import { Plus, Upload } from "lucide-react";
import { FileText, Plus, Upload } from "lucide-react";
import { InvoicesDataTable } from "./_components/invoices-data-table";
import { DataTableSkeleton } from "~/components/data/data-table";
@@ -28,6 +28,12 @@ export default async function InvoicesPage() {
<span>Import CSV</span>
</Link>
</Button>
<Button asChild variant="outline" className="hover-lift shadow-sm">
<Link href="/dashboard/invoices/new?blank=1">
<FileText className="mr-2 h-5 w-5" />
<span>Blank invoice</span>
</Link>
</Button>
<Button asChild variant="default" className="hover-lift shadow-md">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-5 w-5" />
+36 -22
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Label } from "~/components/ui/label";
@@ -95,7 +95,7 @@ function plainTextToHtml(value: string) {
.replace(/\n/g, "<br>");
}
function createDefaultInvoiceFormData(): InvoiceFormData {
function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
return {
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#",
@@ -109,26 +109,30 @@ function createDefaultInvoiceFormData(): InvoiceFormData {
taxRate: 0,
currency: "USD",
defaultHourlyRate: null,
items: [
{
id: crypto.randomUUID(),
date: new Date(),
description: "",
hours: 1,
rate: 0,
amount: 0,
},
],
items: blank
? []
: [
{
id: crypto.randomUUID(),
date: new Date(),
description: "",
hours: 1,
rate: 0,
amount: 0,
},
],
};
}
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const isBlank = searchParams.get("blank") === "1";
const utils = api.useUtils();
// State
const [formData, setFormData] = useState<InvoiceFormData>(
createDefaultInvoiceFormData,
const [formData, setFormData] = useState<InvoiceFormData>(() =>
createDefaultInvoiceFormData(isBlank),
);
const [loading, setLoading] = useState(false);
@@ -368,13 +372,13 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return;
}
// Validate Items - Check for empty description
const itemsToSave = formData.items.filter((item) => item.description?.trim());
let invalidItemIndex = -1;
for (let i = 0; i < formData.items.length; i++) {
if (
!formData.items[i]?.description ||
formData.items[i]?.description.trim() === ""
) {
const item = formData.items[i];
const desc = item?.description?.trim() ?? "";
if (!desc && ((item?.hours ?? 0) > 0 || (item?.rate ?? 0) > 0)) {
invalidItemIndex = i;
break;
}
@@ -421,7 +425,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
emailMessage: formData.emailMessage,
taxRate: formData.taxRate,
currency: formData.currency,
items: formData.items.map((i) => ({
items: itemsToSave.map((i) => ({
date: i.date,
description: i.description,
hours: i.hours,
@@ -460,8 +464,18 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<>
<div className="page-enter space-y-6 pb-8">
<PageHeader
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
description="Manage your invoice"
title={
invoiceId !== "new"
? "Edit Invoice"
: isBlank
? "Blank Invoice"
: "Create Invoice"
}
description={
isBlank
? "Set up a draft to clock time into later"
: "Manage your invoice"
}
variant="gradient"
>
{invoiceId !== "new" && (
+17 -3
View File
@@ -1,6 +1,20 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
/** Docker/Compose pass booleans as strings; z.coerce.boolean() treats "false" as true. */
const optionalEnvBoolean = () =>
z
.union([z.boolean(), z.string()])
.optional()
.transform((value) => {
if (value === undefined || value === "") return undefined;
if (typeof value === "boolean") return value;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1") return true;
if (normalized === "false" || normalized === "0") return false;
return undefined;
});
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
@@ -18,8 +32,8 @@ export const env = createEnv({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
DB_DISABLE_SSL: z.coerce.boolean().optional(),
DISABLE_SIGNUPS: z.coerce.boolean().optional(),
DB_DISABLE_SSL: optionalEnvBoolean(),
DISABLE_SIGNUPS: optionalEnvBoolean(),
CRON_SECRET: z.string().optional(),
// SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().optional(),
@@ -37,7 +51,7 @@ export const env = createEnv({
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().optional(),
NEXT_PUBLIC_AUTHENTIK_ENABLED: z.coerce.boolean().optional(),
NEXT_PUBLIC_AUTHENTIK_ENABLED: optionalEnvBoolean(),
NEXT_PUBLIC_BRAND_NAME: z.string().optional(),
NEXT_PUBLIC_BRAND_TAGLINE: z.string().optional(),
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
+2 -1
View File
@@ -31,7 +31,8 @@ export const auth = betterAuth({
},
}),
trustedOrigins: [
"https://beenvoice.soconnor.dev",
...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []),
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
"beenvoice://",
"exp://",
...(authentikOrigin ? [authentikOrigin] : []),
+4
View File
@@ -70,6 +70,10 @@ export const emailRouter = createTRPCRouter({
throw new Error("Client has no email address");
}
if (!invoice.items.length) {
throw new Error("Add at least one line item before sending this invoice");
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(invoice.client.email)) {
+40 -18
View File
@@ -44,7 +44,7 @@ const createInvoiceSchema = z.object({
taxRate: z.number().min(0).max(100).default(0),
currency: z.string().length(3).default("USD"),
sendReminderAt: z.date().nullable().optional(),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
items: z.array(invoiceItemSchema).min(0, "Items must be an array"),
});
const updateInvoiceSchema = createInvoiceSchema.partial().extend({
@@ -83,6 +83,24 @@ async function verifyBusinessAccess(
return business;
}
async function resolveBusinessForInvoice(
ctx: InvoiceRouterContext,
businessId?: string | null,
) {
if (businessId && businessId.trim() !== "") {
return verifyBusinessAccess(ctx, businessId);
}
const [defaultBusiness] = await ctx.db
.select()
.from(businesses)
.where(eq(businesses.createdById, ctx.session.user.id))
.orderBy(desc(businesses.isDefault), desc(businesses.createdAt))
.limit(1);
return defaultBusiness ?? null;
}
async function verifyClientAccess(ctx: InvoiceRouterContext, clientId: string) {
const client = await ctx.db.query.clients.findFirst({
where: eq(clients.id, clientId),
@@ -349,14 +367,16 @@ export const invoicesRouter = createTRPCRouter({
});
}
await tx.insert(invoiceItems).values(
items.map((item, idx) => ({
...item,
invoiceId: invoice.id,
amount: item.hours * item.rate,
position: idx,
})),
);
if (items.length > 0) {
await tx.insert(invoiceItems).values(
items.map((item, idx) => ({
...item,
invoiceId: invoice.id,
amount: item.hours * item.rate,
position: idx,
})),
);
}
return invoice;
});
@@ -475,14 +495,16 @@ export const invoicesRouter = createTRPCRouter({
await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
await tx.insert(invoiceItems).values(
items.map((item, idx) => ({
...item,
invoiceId: id,
amount: item.hours * item.rate,
position: idx,
})),
);
if (items.length > 0) {
await tx.insert(invoiceItems).values(
items.map((item, idx) => ({
...item,
invoiceId: id,
amount: item.hours * item.rate,
position: idx,
})),
);
}
} else {
const [updatedInvoice] = await tx
.update(invoices)
@@ -658,7 +680,7 @@ export const invoicesRouter = createTRPCRouter({
: null;
const [client, business, settings] = await Promise.all([
verifyClientAccess(ctx, input.clientId),
verifyBusinessAccess(ctx, businessId),
resolveBusinessForInvoice(ctx, businessId),
ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
}),