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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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
@@ -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] : []),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user