FINISHED
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s

This commit is contained in:
2026-01-17 15:56:25 -07:00
parent 3734b2693a
commit 0cd77677f2
36 changed files with 2012 additions and 202 deletions

View File

@@ -1,33 +1,37 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users } from '../../../db/schema';
import { verifyPassword, createSession } from '../../../lib/auth';
import { eq } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import { verifyPassword, createSession } from "../../../lib/auth";
import { eq } from "drizzle-orm";
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
const formData = await request.formData();
const email = formData.get('email')?.toString();
const password = formData.get('password')?.toString();
const email = formData.get("email")?.toString();
const password = formData.get("password")?.toString();
if (!email || !password) {
return new Response('Missing fields', { status: 400 });
return redirect("/login?error=missing_fields");
}
const user = await db.select().from(users).where(eq(users.email, email)).get();
const user = await db
.select()
.from(users)
.where(eq(users.email, email))
.get();
if (!user || !(await verifyPassword(password, user.passwordHash))) {
return new Response('Invalid email or password', { status: 400 });
return redirect("/login?error=invalid_credentials");
}
const { sessionId, expiresAt } = await createSession(user.id);
cookies.set('session_id', sessionId, {
path: '/',
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: 'lax',
sameSite: "lax",
expires: expiresAt,
});
return redirect('/dashboard');
return redirect("/dashboard");
};

View File

@@ -1,39 +1,49 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users, organizations, members, siteSettings } from '../../../db/schema';
import { hashPassword, createSession } from '../../../lib/auth';
import { eq, count, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import {
users,
organizations,
members,
siteSettings,
} from "../../../db/schema";
import { hashPassword, createSession } from "../../../lib/auth";
import { eq, count, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
const userCountResult = await db.select({ count: count() }).from(users).get();
const isFirstUser = userCountResult ? userCountResult.count === 0 : true;
if (!isFirstUser) {
const registrationSetting = await db.select()
const registrationSetting = await db
.select()
.from(siteSettings)
.where(eq(siteSettings.key, 'registration_enabled'))
.where(eq(siteSettings.key, "registration_enabled"))
.get();
const registrationEnabled = registrationSetting?.value === 'true';
const registrationEnabled = registrationSetting?.value === "true";
if (!registrationEnabled) {
return new Response('Registration is currently disabled', { status: 403 });
return redirect("/signup?error=registration_disabled");
}
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
const email = formData.get('email')?.toString();
const password = formData.get('password')?.toString();
const name = formData.get("name")?.toString();
const email = formData.get("email")?.toString();
const password = formData.get("password")?.toString();
if (!name || !email || !password) {
return new Response('Missing fields', { status: 400 });
return redirect("/signup?error=missing_fields");
}
const existingUser = await db.select().from(users).where(eq(users.email, email)).get();
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, email))
.get();
if (existingUser) {
return new Response('User already exists', { status: 400 });
return redirect("/signup?error=user_exists");
}
const passwordHash = await hashPassword(password);
@@ -56,18 +66,18 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
await db.insert(members).values({
userId,
organizationId: orgId,
role: 'owner',
role: "owner",
});
const { sessionId, expiresAt } = await createSession(userId);
cookies.set('session_id', sessionId, {
path: '/',
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: 'lax',
sameSite: "lax",
expires: expiresAt,
});
return redirect('/dashboard');
return redirect("/dashboard");
};

View File

@@ -16,15 +16,33 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
let name: string | undefined;
let email: string | undefined;
let phone: string | undefined;
let street: string | undefined;
let city: string | undefined;
let state: string | undefined;
let zip: string | undefined;
let country: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
email = body.email;
phone = body.phone;
street = body.street;
city = body.city;
state = body.state;
zip = body.zip;
country = body.country;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
email = formData.get("email")?.toString();
phone = formData.get("phone")?.toString();
street = formData.get("street")?.toString();
city = formData.get("city")?.toString();
state = formData.get("state")?.toString();
zip = formData.get("zip")?.toString();
country = formData.get("country")?.toString();
}
if (!name || name.trim().length === 0) {
@@ -74,6 +92,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
.set({
name: name.trim(),
email: email?.trim() || null,
phone: phone?.trim() || null,
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
})
.where(eq(clients.id, id))
.run();
@@ -85,6 +109,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
id,
name: name.trim(),
email: email?.trim() || null,
phone: phone?.trim() || null,
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
}),
{
status: 200,

View File

@@ -12,15 +12,33 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
let name: string | undefined;
let email: string | undefined;
let phone: string | undefined;
let street: string | undefined;
let city: string | undefined;
let state: string | undefined;
let zip: string | undefined;
let country: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
email = body.email;
phone = body.phone;
street = body.street;
city = body.city;
state = body.state;
zip = body.zip;
country = body.country;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
email = formData.get("email")?.toString();
phone = formData.get("phone")?.toString();
street = formData.get("street")?.toString();
city = formData.get("city")?.toString();
state = formData.get("state")?.toString();
zip = formData.get("zip")?.toString();
country = formData.get("country")?.toString();
}
if (!name) {
@@ -44,13 +62,32 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
organizationId: userOrg.organizationId,
name,
email: email || null,
phone: phone || null,
street: street || null,
city: city || null,
state: state || null,
zip: zip || null,
country: country || null,
});
if (locals.scopes) {
return new Response(JSON.stringify({ id, name, email: email || null }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
return new Response(
JSON.stringify({
id,
name,
email: email || null,
phone: phone || null,
street: street || null,
city: city || null,
state: state || null,
zip: zip || null,
country: country || null,
}),
{
status: 201,
headers: { "Content-Type": "application/json" },
},
);
}
return redirect("/dashboard/clients");

View File

@@ -0,0 +1,97 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { invoices, members } from "../../../../db/schema";
import { eq, and, desc } from "drizzle-orm";
export const POST: APIRoute = async ({ redirect, locals, params }) => {
const user = locals.user;
if (!user) {
return redirect("/login");
}
const { id: invoiceId } = params;
if (!invoiceId) {
return new Response("Invoice ID required", { status: 400 });
}
// Fetch invoice to verify existence
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
}
if (invoice.type !== "quote") {
return new Response("Only quotes can be converted to invoices", {
status: 400,
});
}
// Verify membership
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId),
),
)
.get();
if (!membership) {
return new Response("Unauthorized", { status: 401 });
}
try {
// Generate next invoice number
const lastInvoice = await db
.select()
.from(invoices)
.where(
and(
eq(invoices.organizationId, invoice.organizationId),
eq(invoices.type, "invoice"),
),
)
.orderBy(desc(invoices.createdAt))
.limit(1)
.get();
let nextInvoiceNumber = "INV-001";
if (lastInvoice) {
const match = lastInvoice.number.match(/(\d+)$/);
if (match) {
const num = parseInt(match[1]) + 1;
let prefix = lastInvoice.number.replace(match[0], "");
if (prefix === "EST-") prefix = "INV-";
nextInvoiceNumber =
prefix + num.toString().padStart(match[0].length, "0");
}
}
// Convert quote to invoice:
// 1. Change type to 'invoice'
// 2. Set status to 'draft' (so user can review before sending)
// 3. Update number to next invoice sequence
// 4. Update issue date to today
await db
.update(invoices)
.set({
type: "invoice",
status: "draft",
number: nextInvoiceNumber,
issueDate: new Date(),
})
.where(eq(invoices.id, invoiceId));
return redirect(`/dashboard/invoices/${invoiceId}`);
} catch (error) {
console.error("Error converting quote to invoice:", error);
return new Response("Internal Server Error", { status: 500 });
}
};

View File

@@ -69,7 +69,9 @@ export const GET: APIRoute = async ({ params, locals }) => {
// Generate PDF using Vue PDF
// Suppress verbose logging from PDF renderer
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
console.log = () => {};
console.warn = () => {};
try {
const pdfDocument = createInvoiceDocument({
@@ -83,6 +85,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
// Restore console.log
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
const filename = `${invoice.type}_${invoice.number.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`;
@@ -95,6 +98,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
} catch (pdfError) {
// Restore console.log on error
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
throw pdfError;
}
} catch (error) {

View File

@@ -0,0 +1,79 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { invoices, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
export const POST: APIRoute = async ({
request,
redirect,
locals,
params,
}) => {
const user = locals.user;
if (!user) {
return redirect("/login");
}
const { id: invoiceId } = params;
if (!invoiceId) {
return new Response("Invoice ID required", { status: 400 });
}
// Fetch invoice to verify existence
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
}
// Verify membership
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
)
)
.get();
if (!membership) {
return new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const taxRateStr = formData.get("taxRate") as string;
if (taxRateStr === null) {
return new Response("Tax rate is required", { status: 400 });
}
try {
const taxRate = parseFloat(taxRateStr);
if (isNaN(taxRate) || taxRate < 0) {
return new Response("Invalid tax rate", { status: 400 });
}
await db
.update(invoices)
.set({
taxRate,
})
.where(eq(invoices.id, invoiceId));
// Recalculate totals since tax rate changed
await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`);
} catch (error) {
console.error("Error updating invoice tax rate:", error);
return new Response("Internal Server Error", { status: 500 });
}
};

View File

@@ -3,7 +3,12 @@ import { db } from "../../../db";
import { invoices, members } from "../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ request, redirect, locals, cookies }) => {
export const POST: APIRoute = async ({
request,
redirect,
locals,
cookies,
}) => {
const user = locals.user;
if (!user) {
return redirect("/login");
@@ -36,7 +41,8 @@ export const POST: APIRoute = async ({ request, redirect, locals, cookies }) =>
}
const membership = currentTeamId
? userMemberships.find((m) => m.organizationId === currentTeamId)
? userMemberships.find((m) => m.organizationId === currentTeamId) ||
userMemberships[0]
: userMemberships[0];
if (!membership) {
@@ -72,3 +78,7 @@ export const POST: APIRoute = async ({ request, redirect, locals, cookies }) =>
return new Response("Internal Server Error", { status: 500 });
}
};
export const GET: APIRoute = async ({ redirect }) => {
return redirect("/dashboard/invoices/new");
};

View File

@@ -1,4 +1,6 @@
import type { APIRoute } from "astro";
import { promises as fs } from "fs";
import path from "path";
import { db } from "../../../db";
import { organizations, members } from "../../../db/schema";
import { eq, and } from "drizzle-orm";
@@ -17,6 +19,7 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
const state = formData.get("state") as string | null;
const zip = formData.get("zip") as string | null;
const country = formData.get("country") as string | null;
const logo = formData.get("logo") as File | null;
if (!organizationId || !name || name.trim().length === 0) {
return new Response("Organization ID and name are required", {
@@ -49,17 +52,63 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
);
}
let logoUrl: string | undefined;
if (logo && logo.size > 0) {
const allowedTypes = ["image/png", "image/jpeg"];
if (!allowedTypes.includes(logo.type)) {
return new Response(
"Invalid file type. Only PNG and JPG are allowed.",
{
status: 400,
},
);
}
const ext = logo.name.split(".").pop() || "png";
const filename = `${organizationId}-${Date.now()}.${ext}`;
let uploadDir;
const envRootDir = process.env.ROOT_DIR
? process.env.ROOT_DIR
: import.meta.env.ROOT_DIR;
if (envRootDir) {
uploadDir = path.join(envRootDir, "uploads");
} else {
uploadDir =
process.env.UPLOAD_DIR ||
path.join(process.cwd(), "public", "uploads");
}
try {
await fs.access(uploadDir);
} catch {
await fs.mkdir(uploadDir, { recursive: true });
}
const buffer = Buffer.from(await logo.arrayBuffer());
await fs.writeFile(path.join(uploadDir, filename), buffer);
logoUrl = `/uploads/${filename}`;
}
// Update organization information
const updateData: any = {
name: name.trim(),
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
};
if (logoUrl) {
updateData.logoUrl = logoUrl;
}
await db
.update(organizations)
.set({
name: name.trim(),
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
})
.set(updateData)
.where(eq(organizations.id, organizationId))
.run();