This commit is contained in:
phaichayon
2026-04-23 15:37:01 +07:00
parent 67960174d3
commit a330abf9b6
36 changed files with 4656 additions and 278 deletions

View File

@@ -0,0 +1,64 @@
import { Elysia, t } from "elysia";
import { authPlugin } from "@/middleware/auth";
import type { User } from "@/database/schema";
import type { KeycloakTokenPayload } from "@/lib/keycloak";
export const auth = new Elysia({ prefix: "/auth", tags: ["auth"] })
.use(authPlugin)
// GET /api/auth/me - Get current user info
.get(
"/me",
(context: any) => {
const user = context.user as User;
const tokenPayload = context.tokenPayload as KeycloakTokenPayload;
if (!user || !tokenPayload) {
throw new Error("Unauthorized");
}
return {
success: true as const,
data: {
user: {
id: user.id,
keycloakId: user.keycloakId,
email: user.email,
name: user.name,
createdAt: user.createdAt.toISOString(),
},
tokenInfo: {
sub: tokenPayload.sub,
email: tokenPayload.email,
name: tokenPayload.name,
exp: tokenPayload.exp,
iat: tokenPayload.iat,
},
},
};
},
{
response: t.Object({
success: t.Literal(true),
data: t.Object({
user: t.Object({
id: t.String(),
keycloakId: t.String(),
email: t.String(),
name: t.String(),
createdAt: t.String(),
}),
tokenInfo: t.Object({
sub: t.String(),
email: t.Optional(t.String()),
name: t.Optional(t.String()),
exp: t.Number(),
iat: t.Number(),
}),
}),
}),
detail: {
description: "Get current authenticated user information",
security: [{ Bearer: [] }],
},
},
);

View File

@@ -0,0 +1,47 @@
import { db } from "@/database/db";
import { users } from "@/database/schema";
import { eq } from "drizzle-orm";
import type { KeycloakTokenPayload } from "@/lib/keycloak";
/**
* Find or create user based on Keycloak token payload
* @param payload Keycloak token payload
* @returns User record from database
*/
export async function findOrCreateUser(payload: KeycloakTokenPayload) {
// Try to find existing user by keycloakId
const existingUser = await db
.select()
.from(users)
.where(eq(users.keycloakId, payload.sub))
.limit(1);
if (existingUser.length > 0) {
return existingUser[0];
}
// Create new user if not found
const newUser = {
keycloakId: payload.sub,
email: payload.email || "",
name: payload.name || payload.preferred_username || "Unknown User",
};
const [createdUser] = await db.insert(users).values(newUser).returning();
return createdUser;
}
/**
* Get user by Keycloak ID
* @param keycloakId The Keycloak user ID
* @returns User record or null
*/
export async function getUserByKeycloakId(keycloakId: string) {
const result = await db
.select()
.from(users)
.where(eq(users.keycloakId, keycloakId))
.limit(1);
return result[0] || null;
}

View File

@@ -0,0 +1,216 @@
import { Elysia, t } from "elysia";
import * as service from "./service";
import { CustomerModel } from "./model";
// Create Elysia instance for customers module
export const customers = new Elysia({
prefix: "/customers",
tags: ["customers"],
})
.model(CustomerModel)
// GET /api/customers/:branch - Get all customers by branch
.get(
"/:branch",
({ params, query }) => {
const { branch } = params;
const { status } = query as { status?: string };
const customers = service.getAllCustomers(
branch,
status as "active" | "inactive" | "pending" | undefined,
);
return {
success: true,
data: customers,
count: customers.length,
message: `Found ${customers.length} customer(s) for branch: ${branch}`,
};
},
{
params: t.Object({
branch: t.String(),
}),
query: t.Optional(
t.Object({
status: t.Optional(
t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
),
}),
),
response: CustomerModel.CustomerList,
detail: {
description: "Get all customers for a specific branch",
parameters: [
{
name: "branch",
in: "path",
required: true,
schema: { type: "string" },
description:
"Branch identifier (e.g., branch-01, branch-02, head-office)",
},
{
name: "status",
in: "query",
required: false,
schema: {
type: "string",
enum: ["active", "inactive", "pending"],
},
description: "Filter customers by status",
},
],
},
},
)
// GET /api/customers/:branch/:id - Get single customer by ID
.get(
"/:branch/:id",
({ params }) => {
const { branch, id } = params;
const customer = service.getCustomerByIdAndBranch(branch, id);
if (!customer) {
return {
success: false,
error: "Customer not found",
};
}
return {
success: true,
data: customer,
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: CustomerModel.Customer,
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Get a single customer by ID and branch",
},
},
)
// POST /api/customers - Create new customer
.post(
"/",
({ body }) => {
const customer = service.createCustomer(body);
return {
success: true,
data: customer,
message: "Customer created successfully",
};
},
{
body: CustomerModel.CreateCustomer,
response: t.Object({
success: t.Boolean(),
data: CustomerModel.Customer,
message: t.String(),
}),
detail: {
description: "Create a new customer",
},
},
)
// PUT /api/customers/:branch/:id - Update customer
.put(
"/:branch/:id",
({ params, body }) => {
const { branch, id } = params;
const customer = service.updateCustomer(branch, id, body);
if (!customer) {
return {
success: false,
error: "Customer not found",
};
}
return {
success: true,
data: customer,
message: "Customer updated successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
body: CustomerModel.UpdateCustomer,
response: t.Union([
t.Object({
success: t.Literal(true),
data: CustomerModel.Customer,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Update an existing customer",
},
},
)
// DELETE /api/customers/:branch/:id - Delete customer
.delete(
"/:branch/:id",
({ params }) => {
const { branch, id } = params;
const customer = service.deleteCustomer(branch, id);
if (!customer) {
return {
success: false,
error: "Customer not found",
};
}
return {
success: true,
data: customer,
message: "Customer deleted successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: CustomerModel.Customer,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Delete a customer",
},
},
);

View File

@@ -0,0 +1,82 @@
import { t } from "elysia";
// Schemas for validation
export const CustomerModel = {
Customer: t.Object({
id: t.String(),
branch: t.String(),
name: t.String(),
email: t.String({ format: "email" }),
phone: t.String(),
company: t.String(),
address: t.String(),
status: t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
}),
CreateCustomer: t.Object({
branch: t.String(),
name: t.String(),
email: t.String({ format: "email" }),
phone: t.String(),
company: t.String(),
address: t.String(),
status: t.Optional(
t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
),
}),
UpdateCustomer: t.Object({
name: t.Optional(t.String()),
email: t.Optional(t.String({ format: "email" })),
phone: t.Optional(t.String()),
company: t.Optional(t.String()),
address: t.Optional(t.String()),
status: t.Optional(
t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
),
}),
CustomerList: t.Object({
success: t.Boolean(),
data: t.Array(
t.Object({
id: t.String(),
branch: t.String(),
name: t.String(),
email: t.String(),
phone: t.String(),
company: t.String(),
address: t.String(),
status: t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
createdAt: t.String(),
updatedAt: t.String(),
}),
),
count: t.Number(),
message: t.Optional(t.String()),
}),
};
// Export types from schemas
export type Customer = typeof CustomerModel.Customer.static;
export type CreateCustomer = typeof CustomerModel.CreateCustomer.static;
export type UpdateCustomer = typeof CustomerModel.UpdateCustomer.static;
export type CustomerList = typeof CustomerModel.CustomerList.static;

View File

@@ -0,0 +1,109 @@
import { getCustomersByBranch, getCustomerById } from "@/lib/mock-data";
import type { Customer, CreateCustomer, UpdateCustomer } from "./model";
/**
* Get all customers for a specific branch
* @param branch - Branch identifier
* @param status - Optional status filter
* @returns Array of customers
*/
export function getAllCustomers(
branch: string,
status?: "active" | "inactive" | "pending",
): Customer[] {
let customers = getCustomersByBranch(branch);
if (status) {
customers = customers.filter((customer) => customer.status === status);
}
return customers;
}
/**
* Get a single customer by ID and branch
* @param branch - Branch identifier
* @param id - Customer ID
* @returns Customer or undefined if not found
*/
export function getCustomerByIdAndBranch(
branch: string,
id: string,
): Customer | undefined {
const customer = getCustomerById(id);
// Only return if customer belongs to the specified branch
if (customer && customer.branch === branch) {
return customer;
}
return undefined;
}
/**
* Create a new customer
* @param data - Customer creation data
* @returns Newly created customer
*/
export function createCustomer(data: CreateCustomer): Customer {
const newCustomer: Customer = {
id: `cust-${Date.now()}`,
...data,
status: data.status || "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// In a real app, this would save to database
// For now, we'll just return the new customer
return newCustomer;
}
/**
* Update an existing customer
* @param branch - Branch identifier
* @param id - Customer ID
* @param data - Customer update data
* @returns Updated customer or undefined if not found
*/
export function updateCustomer(
branch: string,
id: string,
data: UpdateCustomer,
): Customer | undefined {
const customer = getCustomerByIdAndBranch(branch, id);
if (!customer) {
return undefined;
}
// Merge update data
const updatedCustomer: Customer = {
...customer,
...data,
updatedAt: new Date().toISOString(),
};
// In a real app, this would update database
return updatedCustomer;
}
/**
* Delete a customer
* @param branch - Branch identifier
* @param id - Customer ID
* @returns Deleted customer or undefined if not found
*/
export function deleteCustomer(
branch: string,
id: string,
): Customer | undefined {
const customer = getCustomerByIdAndBranch(branch, id);
if (!customer) {
return undefined;
}
// In a real app, this would delete from database
return customer;
}

View File

@@ -0,0 +1,224 @@
import { Elysia, t } from "elysia";
import * as service from "./service";
import { QuotationModel } from "./model";
// Create Elysia instance for quotations module
export const quotations = new Elysia({
prefix: "/quotations",
tags: ["quotations"],
})
.model(QuotationModel)
// GET /api/quotations/:branch - Get all quotations by branch
.get(
"/:branch",
({ params, query }) => {
const { branch } = params;
const { status } = query as { status?: string };
const quotations = service.getAllQuotations(
branch,
status as
| "draft"
| "sent"
| "accepted"
| "rejected"
| "expired"
| undefined,
);
return {
success: true,
data: quotations,
count: quotations.length,
message: `Found ${quotations.length} quotation(s) for branch: ${branch}`,
};
},
{
params: t.Object({
branch: t.String(),
}),
query: t.Optional(
t.Object({
status: t.Optional(
t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
]),
),
}),
),
response: QuotationModel.QuotationList,
detail: {
description: "Get all quotations for a specific branch",
parameters: [
{
name: "branch",
in: "path",
required: true,
schema: { type: "string" },
description:
"Branch identifier (e.g., branch-01, branch-02, head-office)",
},
{
name: "status",
in: "query",
required: false,
schema: {
type: "string",
enum: ["draft", "sent", "accepted", "rejected", "expired"],
},
description: "Filter quotations by status",
},
],
},
},
)
// GET /api/quotations/:branch/:id - Get single quotation by ID
.get(
"/:branch/:id",
({ params }) => {
const { branch, id } = params;
const quotation = service.getQuotationByIdAndBranch(branch, id);
if (!quotation) {
return {
success: false,
error: "Quotation not found",
};
}
return {
success: true,
data: quotation,
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: QuotationModel.Quotation,
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Get a single quotation by ID and branch",
},
},
)
// POST /api/quotations - Create new quotation
.post(
"/",
({ body }) => {
const quotation = service.createQuotation(body);
return {
success: true,
data: quotation,
message: "Quotation created successfully",
};
},
{
body: QuotationModel.CreateQuotation,
response: t.Object({
success: t.Boolean(),
data: QuotationModel.Quotation,
message: t.String(),
}),
detail: {
description: "Create a new quotation",
},
},
)
// PUT /api/quotations/:branch/:id - Update quotation
.put(
"/:branch/:id",
({ params, body }) => {
const { branch, id } = params;
const quotation = service.updateQuotation(branch, id, body);
if (!quotation) {
return {
success: false,
error: "Quotation not found",
};
}
return {
success: true,
data: quotation,
message: "Quotation updated successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
body: QuotationModel.UpdateQuotation,
response: t.Union([
t.Object({
success: t.Literal(true),
data: QuotationModel.Quotation,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Update an existing quotation",
},
},
)
// DELETE /api/quotations/:branch/:id - Delete quotation
.delete(
"/:branch/:id",
({ params }) => {
const { branch, id } = params;
const quotation = service.deleteQuotation(branch, id);
if (!quotation) {
return {
success: false,
error: "Quotation not found",
};
}
return {
success: true,
data: quotation,
message: "Quotation deleted successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: QuotationModel.Quotation,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Delete a quotation",
},
},
);

View File

@@ -0,0 +1,104 @@
import { t } from "elysia";
// Schemas for validation
export const QuotationModel = {
Quotation: t.Object({
id: t.String(),
quotationNumber: t.String(),
branch: t.String(),
customerId: t.String(),
customerName: t.String(),
date: t.String({ format: "date-time" }),
validUntil: t.String({ format: "date-time" }),
subtotal: t.Number(),
taxRate: t.Number(),
taxAmount: t.Number(),
totalAmount: t.Number(),
status: t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
]),
notes: t.Optional(t.String()),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
}),
CreateQuotation: t.Object({
branch: t.String(),
customerId: t.String(),
customerName: t.String(),
date: t.String({ format: "date-time" }),
validUntil: t.String({ format: "date-time" }),
subtotal: t.Number(),
taxRate: t.Number(),
notes: t.Optional(t.String()),
status: t.Optional(
t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
]),
),
}),
UpdateQuotation: t.Object({
customerId: t.Optional(t.String()),
customerName: t.Optional(t.String()),
date: t.Optional(t.String({ format: "date-time" })),
validUntil: t.Optional(t.String({ format: "date-time" })),
subtotal: t.Optional(t.Number()),
taxRate: t.Optional(t.Number()),
notes: t.Optional(t.String()),
status: t.Optional(
t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
]),
),
}),
QuotationList: t.Object({
success: t.Boolean(),
data: t.Array(
t.Object({
id: t.String(),
quotationNumber: t.String(),
branch: t.String(),
customerId: t.String(),
customerName: t.String(),
date: t.String(),
validUntil: t.String(),
subtotal: t.Number(),
taxRate: t.Number(),
taxAmount: t.Number(),
totalAmount: t.Number(),
status: t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
]),
notes: t.Optional(t.String()),
createdAt: t.String(),
updatedAt: t.String(),
}),
),
count: t.Number(),
message: t.Optional(t.String()),
}),
};
// Export types from schemas
export type Quotation = typeof QuotationModel.Quotation.static;
export type CreateQuotation = typeof QuotationModel.CreateQuotation.static;
export type UpdateQuotation = typeof QuotationModel.UpdateQuotation.static;
export type QuotationList = typeof QuotationModel.QuotationList.static;

View File

@@ -0,0 +1,222 @@
import type { Quotation, CreateQuotation, UpdateQuotation } from "./model";
// Mock quotations data
const mockQuotations: Quotation[] = [
{
id: "quot-001",
quotationNumber: "QT-2024-001",
branch: "branch-01",
customerId: "cust-001",
customerName: "สมชาย ใจดี",
date: "2024-01-20T00:00:00Z",
validUntil: "2024-02-20T00:00:00Z",
subtotal: 50000,
taxRate: 0.07,
taxAmount: 3500,
totalAmount: 53500,
status: "sent",
notes: "Quotation for office supplies",
createdAt: "2024-01-20T09:00:00Z",
updatedAt: "2024-01-20T09:00:00Z",
},
{
id: "quot-002",
quotationNumber: "QT-2024-002",
branch: "branch-01",
customerId: "cust-002",
customerName: "วิภา สุขสันต์",
date: "2024-02-25T00:00:00Z",
validUntil: "2024-03-25T00:00:00Z",
subtotal: 120000,
taxRate: 0.07,
taxAmount: 8400,
totalAmount: 128400,
status: "accepted",
notes: "Quotation for computer equipment",
createdAt: "2024-02-25T10:30:00Z",
updatedAt: "2024-02-28T14:20:00Z",
},
{
id: "quot-003",
quotationNumber: "QT-2024-003",
branch: "branch-02",
customerId: "cust-004",
customerName: "มานี มีสุข",
date: "2024-03-10T00:00:00Z",
validUntil: "2024-04-10T00:00:00Z",
subtotal: 75000,
taxRate: 0.07,
taxAmount: 5250,
totalAmount: 80250,
status: "draft",
notes: null,
createdAt: "2024-03-10T11:00:00Z",
updatedAt: "2024-03-10T11:00:00Z",
},
{
id: "quot-004",
quotationNumber: "QT-2024-004",
branch: "head-office",
customerId: "cust-007",
customerName: "ภูมิ รักษ์โลก",
date: "2024-04-01T00:00:00Z",
validUntil: "2024-05-01T00:00:00Z",
subtotal: 200000,
taxRate: 0.07,
taxAmount: 14000,
totalAmount: 214000,
status: "sent",
notes: "Quotation for laboratory equipment",
createdAt: "2024-04-01T09:30:00Z",
updatedAt: "2024-04-01T09:30:00Z",
},
];
/**
* Get all quotations for a specific branch
* @param branch - Branch identifier
* @param status - Optional status filter
* @returns Array of quotations
*/
export function getAllQuotations(
branch: string,
status?: "draft" | "sent" | "accepted" | "rejected" | "expired",
): Quotation[] {
let quotations = mockQuotations.filter((q) => q.branch === branch);
if (status) {
quotations = quotations.filter((q) => q.status === status);
}
return quotations;
}
/**
* Get a single quotation by ID and branch
* @param branch - Branch identifier
* @param id - Quotation ID
* @returns Quotation or undefined if not found
*/
export function getQuotationByIdAndBranch(
branch: string,
id: string,
): Quotation | undefined {
const quotation = mockQuotations.find((q) => q.id === id);
// Only return if quotation belongs to the specified branch
if (quotation && quotation.branch === branch) {
return quotation;
}
return undefined;
}
/**
* Calculate tax and total amounts
* @param subtotal - Subtotal amount
* @param taxRate - Tax rate (e.g., 0.07 for 7%)
* @returns Object with taxAmount and totalAmount
*/
function calculateTotals(subtotal: number, taxRate: number) {
const taxAmount = subtotal * taxRate;
const totalAmount = subtotal + taxAmount;
return { taxAmount, totalAmount };
}
/**
* Create a new quotation
* @param data - Quotation creation data
* @returns Newly created quotation
*/
export function createQuotation(data: CreateQuotation): Quotation {
const { taxAmount, totalAmount } = calculateTotals(
data.subtotal,
data.taxRate,
);
const newQuotation: Quotation = {
id: `quot-${Date.now()}`,
quotationNumber: `QT-${new Date().getFullYear()}-${String(mockQuotations.length + 1).padStart(3, "0")}`,
...data,
taxAmount,
totalAmount,
status: data.status || "draft",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// In a real app, this would save to database
mockQuotations.push(newQuotation);
return newQuotation;
}
/**
* Update an existing quotation
* @param branch - Branch identifier
* @param id - Quotation ID
* @param data - Quotation update data
* @returns Updated quotation or undefined if not found
*/
export function updateQuotation(
branch: string,
id: string,
data: UpdateQuotation,
): Quotation | undefined {
const quotation = getQuotationByIdAndBranch(branch, id);
if (!quotation) {
return undefined;
}
// Recalculate totals if subtotal or taxRate changed
let { taxAmount, totalAmount } = quotation;
if (data.subtotal !== undefined || data.taxRate !== undefined) {
const newSubtotal = data.subtotal ?? quotation.subtotal;
const newTaxRate = data.taxRate ?? quotation.taxRate;
const calculated = calculateTotals(newSubtotal, newTaxRate);
taxAmount = calculated.taxAmount;
totalAmount = calculated.totalAmount;
}
// Merge update data
const updatedQuotation: Quotation = {
...quotation,
...data,
taxAmount,
totalAmount,
updatedAt: new Date().toISOString(),
};
// In a real app, this would update database
const index = mockQuotations.findIndex((q) => q.id === id);
if (index !== -1) {
mockQuotations[index] = updatedQuotation;
}
return updatedQuotation;
}
/**
* Delete a quotation
* @param branch - Branch identifier
* @param id - Quotation ID
* @returns Deleted quotation or undefined if not found
*/
export function deleteQuotation(
branch: string,
id: string,
): Quotation | undefined {
const quotation = getQuotationByIdAndBranch(branch, id);
if (!quotation) {
return undefined;
}
// In a real app, this would delete from database
const index = mockQuotations.findIndex((q) => q.id === id);
if (index !== -1) {
mockQuotations.splice(index, 1);
}
return quotation;
}