This commit is contained in:
2026-06-02 10:23:09 +03:00
parent e08d555b23
commit 0c87eaef46
136 changed files with 16069 additions and 94 deletions

202
app/actions/auth.ts Normal file
View File

@@ -0,0 +1,202 @@
'use server';
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { hashPassword, verifyPassword, generateToken } from "@/lib/crypto";
import {
createSession,
deleteSession,
requireCurrentUser,
} from "@/lib/auth";
import { Resend } from "resend";
import type { FormState } from "@/lib/types";
export async function login(
prevState: FormState | undefined,
formData: FormData,
): Promise<FormState> {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!email || !password) {
return { message: "Email and password are required" };
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return { message: "Invalid email or password" };
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
return { message: "Invalid email or password" };
}
await createSession(user.id);
redirect("/");
}
export async function logout(): Promise<never> {
await deleteSession();
redirect("/login");
}
export async function changePassword(
prevState: FormState | undefined,
formData: FormData,
): Promise<FormState> {
const currentUser = await requireCurrentUser();
const currentPassword = formData.get("currentPassword") as string;
const newPassword = formData.get("newPassword") as string;
const confirmPassword = formData.get("confirmPassword") as string;
if (!currentPassword || !newPassword || !confirmPassword) {
return { message: "All fields are required" };
}
if (newPassword.length < 8) {
return {
errors: { newPassword: ["Password must be at least 8 characters"] },
message: "Password must be at least 8 characters",
};
}
if (newPassword !== confirmPassword) {
return {
errors: { confirmPassword: ["Passwords do not match"] },
message: "Passwords do not match",
};
}
const user = await prisma.user.findUnique({
where: { id: currentUser.id },
});
if (!user) {
return { message: "User not found" };
}
const valid = await verifyPassword(currentPassword, user.passwordHash);
if (!valid) {
return {
errors: { currentPassword: ["Current password is incorrect"] },
message: "Current password is incorrect",
};
}
const newHash = await hashPassword(newPassword);
await prisma.user.update({
where: { id: user.id },
data: { passwordHash: newHash },
});
return { message: "Password changed successfully", success: true };
}
export async function forgotPassword(
prevState: FormState | undefined,
formData: FormData,
): Promise<FormState> {
const email = formData.get("email") as string;
if (!email) {
return { message: "Email is required" };
}
const user = await prisma.user.findUnique({ where: { email } });
if (user) {
const token = generateToken();
await prisma.passwordResetToken.create({
data: {
token,
userId: user.id,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
const resetLink = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${token}`;
if (process.env.RESEND_API_KEY) {
try {
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: "Order Loop <onboarding@resend.dev>",
to: email,
subject: "Reset your password",
html: `<p>Click <a href="${resetLink}">here</a> to reset your password. This link expires in 1 hour.</p>`,
});
} catch (error) {
console.error("Failed to send reset email:", error);
console.log("Password reset link:", resetLink);
}
} else {
console.log("Password reset link:", resetLink);
}
}
return {
message: "If an account exists, a reset link has been sent",
success: true,
};
}
export async function resetPassword(
prevState: FormState | undefined,
formData: FormData,
): Promise<FormState> {
const token = formData.get("token") as string;
const password = formData.get("password") as string;
const confirmPassword = formData.get("confirmPassword") as string;
if (!token || !password || !confirmPassword) {
return { message: "All fields are required" };
}
if (password.length < 8) {
return {
errors: { password: ["Password must be at least 8 characters"] },
message: "Password must be at least 8 characters",
};
}
if (password !== confirmPassword) {
return {
errors: { confirmPassword: ["Passwords do not match"] },
message: "Passwords do not match",
};
}
const resetToken = await prisma.passwordResetToken.findUnique({
where: { token },
});
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
return { message: "Invalid or expired reset token" };
}
const newHash = await hashPassword(password);
await prisma.user.update({
where: { id: resetToken.userId },
data: { passwordHash: newHash },
});
await prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { used: true },
});
return {
message: "Password reset successfully. You can now log in.",
success: true,
};
}

375
app/actions/orders.ts Normal file
View File

@@ -0,0 +1,375 @@
'use server'
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireCurrentUser } from "@/lib/auth";
import { Prisma } from "@prisma/client";
import type {
OrderWithDetails,
DashboardStats,
FormState,
OrderStatus,
} from "@/lib/types";
// ---------------------------------------------------------------------------
// Types for parsed JSON payloads from hidden form inputs
// ---------------------------------------------------------------------------
interface ParsedCustomer {
id: string;
name: string;
}
interface ParsedItem {
id: string;
customerId: string | null;
itemName: string;
initPrice: number;
myPrice: number;
taxRatio: number;
quantity: number;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function computePrices(item: ParsedItem) {
const netPrice = (item.initPrice + item.initPrice * item.taxRatio) * item.quantity;
const myNetPrice = (item.myPrice + item.myPrice * item.taxRatio) * item.quantity;
const finalPrice = netPrice;
return { netPrice, myNetPrice, finalPrice };
}
/**
* Resolve a parsed customer to a database Customer id.
* First tries to find an existing customer by id, then by name.
* If neither exists, creates a new Customer record.
*/
async function resolveCustomer(
customer: ParsedCustomer,
tx: Parameters<Parameters<typeof prisma.$transaction>[0]>[0],
): Promise<string> {
// Try by id first
const existing = await tx.customer.findUnique({
where: { id: customer.id },
});
if (existing) return existing.id;
// Try by name (prevents duplicates from re-adding)
const byName = await tx.customer.findUnique({
where: { name: customer.name },
});
if (byName) return byName.id;
// Create new
const created = await tx.customer.create({
data: { name: customer.name },
});
return created.id;
}
// ---------------------------------------------------------------------------
// createOrder
// ---------------------------------------------------------------------------
export async function createOrder(
prevState: FormState | undefined,
formData: FormData,
): Promise<FormState> {
await requireCurrentUser();
const title = (formData.get("title") as string) ?? "";
const status = ((formData.get("status") as string) ?? "pending") as OrderStatus;
const notes = (formData.get("notes") as string) || null;
const customersRaw = formData.get("customers") as string | null;
const itemsRaw = formData.get("items") as string | null;
const customers: ParsedCustomer[] = customersRaw ? JSON.parse(customersRaw) : [];
const items: ParsedItem[] = itemsRaw ? JSON.parse(itemsRaw) : [];
// Validation
const errors: Record<string, string[]> = {};
if (!title.trim()) {
errors.title = ["Title is required"];
}
if (items.length === 0) {
errors.items = ["At least one item is required"];
}
if (Object.keys(errors).length > 0) {
return { errors };
}
// Transaction
const order = await prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: { title: title.trim(), status, notes },
});
// Resolve customer IDs (maps client-side "new-xxx" IDs to real DB IDs)
const customerIdMap: Record<string, string> = {};
for (const c of customers) {
const dbId = await resolveCustomer(c, tx);
customerIdMap[c.id] = dbId;
await tx.orderCustomer.create({
data: { orderId: created.id, customerId: dbId },
});
}
for (const item of items) {
const { netPrice, myNetPrice, finalPrice } = computePrices(item);
// Resolve item's customerId through the map (handles "new-xxx" references)
const resolvedCustomerId = item.customerId
? (customerIdMap[item.customerId] ?? item.customerId)
: null;
await tx.orderItem.create({
data: {
orderId: created.id,
customerId: resolvedCustomerId,
itemName: item.itemName,
initPrice: item.initPrice,
myPrice: item.myPrice,
taxRatio: item.taxRatio,
quantity: item.quantity,
netPrice,
myNetPrice,
finalPrice,
},
});
}
return created;
});
revalidatePath("/");
redirect(`/orders/${order.id}`);
}
// ---------------------------------------------------------------------------
// updateOrder
// ---------------------------------------------------------------------------
export async function updateOrder(
orderId: string,
prevState: FormState | undefined,
formData: FormData,
): Promise<FormState> {
await requireCurrentUser();
// Fetch existing order and verify it is not closed
const existing = await prisma.order.findUnique({ where: { id: orderId } });
if (!existing) {
return { message: "Order not found" };
}
if (existing.status === "closed") {
return { message: "Cannot edit a closed order" };
}
const title = (formData.get("title") as string) ?? "";
const status = ((formData.get("status") as string) ?? existing.status) as OrderStatus;
const notes = (formData.get("notes") as string) || null;
const customersRaw = formData.get("customers") as string | null;
const itemsRaw = formData.get("items") as string | null;
const customers: ParsedCustomer[] = customersRaw ? JSON.parse(customersRaw) : [];
const items: ParsedItem[] = itemsRaw ? JSON.parse(itemsRaw) : [];
// Validation
const errors: Record<string, string[]> = {};
if (!title.trim()) {
errors.title = ["Title is required"];
}
if (items.length === 0) {
errors.items = ["At least one item is required"];
}
if (Object.keys(errors).length > 0) {
return { errors };
}
// Transaction: delete old relations, update order, recreate relations
await prisma.$transaction(async (tx) => {
await tx.orderCustomer.deleteMany({ where: { orderId } });
await tx.orderItem.deleteMany({ where: { orderId } });
await tx.order.update({
where: { id: orderId },
data: { title: title.trim(), status, notes },
});
// Resolve customer IDs (maps client-side "new-xxx" IDs to real DB IDs)
const customerIdMap: Record<string, string> = {};
for (const c of customers) {
const dbId = await resolveCustomer(c, tx);
customerIdMap[c.id] = dbId;
await tx.orderCustomer.create({
data: { orderId, customerId: dbId },
});
}
for (const item of items) {
const { netPrice, myNetPrice, finalPrice } = computePrices(item);
// Resolve item's customerId through the map (handles "new-xxx" references)
const resolvedCustomerId = item.customerId
? (customerIdMap[item.customerId] ?? item.customerId)
: null;
await tx.orderItem.create({
data: {
orderId,
customerId: resolvedCustomerId,
itemName: item.itemName,
initPrice: item.initPrice,
myPrice: item.myPrice,
taxRatio: item.taxRatio,
quantity: item.quantity,
netPrice,
myNetPrice,
finalPrice,
},
});
}
});
revalidatePath("/");
revalidatePath(`/orders/${orderId}`);
return { message: "Order saved successfully", success: true };
}
// ---------------------------------------------------------------------------
// getOrders
// ---------------------------------------------------------------------------
export async function getOrders(
search?: string,
statusFilter?: string,
): Promise<OrderWithDetails[]> {
await requireCurrentUser();
const where: Prisma.OrderWhereInput = {};
if (search) {
where.OR = [
{ title: { contains: search } },
{ customers: { some: { customer: { name: { contains: search } } } } },
];
}
if (statusFilter) {
where.status = statusFilter;
}
const orders = await prisma.order.findMany({
where,
include: {
customers: { include: { customer: true } },
items: { include: { customer: true } },
},
orderBy: { updatedAt: "desc" },
});
return orders.map((order) => ({
id: order.id,
title: order.title,
status: order.status as OrderStatus,
notes: order.notes,
createdAt: order.createdAt,
updatedAt: order.updatedAt,
customers: order.customers.map((oc) => ({
customer: { id: oc.customer.id, name: oc.customer.name },
})),
items: order.items.map((item) => ({
id: item.id,
orderId: item.orderId,
customerId: item.customerId,
customerName: item.customer?.name ?? null,
itemName: item.itemName,
initPrice: item.initPrice,
myPrice: item.myPrice,
taxRatio: item.taxRatio,
quantity: item.quantity,
netPrice: item.netPrice,
myNetPrice: item.myNetPrice,
finalPrice: item.finalPrice,
})),
}));
}
// ---------------------------------------------------------------------------
// getOrderById
// ---------------------------------------------------------------------------
export async function getOrderById(
orderId: string,
): Promise<OrderWithDetails | null> {
await requireCurrentUser();
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
customers: { include: { customer: true } },
items: { include: { customer: true } },
},
});
if (!order) return null;
return {
id: order.id,
title: order.title,
status: order.status as OrderStatus,
notes: order.notes,
createdAt: order.createdAt,
updatedAt: order.updatedAt,
customers: order.customers.map((oc) => ({
customer: { id: oc.customer.id, name: oc.customer.name },
})),
items: order.items.map((item) => ({
id: item.id,
orderId: item.orderId,
customerId: item.customerId,
customerName: item.customer?.name ?? null,
itemName: item.itemName,
initPrice: item.initPrice,
myPrice: item.myPrice,
taxRatio: item.taxRatio,
quantity: item.quantity,
netPrice: item.netPrice,
myNetPrice: item.myNetPrice,
finalPrice: item.finalPrice,
})),
};
}
// ---------------------------------------------------------------------------
// getDashboardStats
// ---------------------------------------------------------------------------
export async function getDashboardStats(): Promise<DashboardStats> {
await requireCurrentUser();
const groups = await prisma.order.groupBy({
by: ["status"],
_count: { id: true },
});
const counts: Record<string, number> = {};
for (const g of groups) {
counts[g.status] = g._count.id;
}
return {
pending: counts["pending"] ?? 0,
purchased: counts["purchased"] ?? 0,
delivered: counts["delivered"] ?? 0,
closed: counts["closed"] ?? 0,
totalOrders: Object.values(counts).reduce((sum, n) => sum + n, 0),
};
}