This commit is contained in:
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
97
src/pages/api/invoices/[id]/convert.ts
Normal file
97
src/pages/api/invoices/[id]/convert.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
79
src/pages/api/invoices/[id]/update-tax.ts
Normal file
79
src/pages/api/invoices/[id]/update-tax.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user