This commit is contained in:
phaichayon
2026-04-26 00:15:22 +07:00
parent a330abf9b6
commit 043edff93a
64 changed files with 25076 additions and 461 deletions

View File

@@ -0,0 +1,380 @@
import { Elysia, t } from "elysia";
import * as service from "./service";
import { AuditLogModel } from "./model";
import { branchMiddleware } from "@/middleware/branch";
// Create Elysia instance for audit logs module
export const auditLogs = new Elysia({
prefix: "/audit-logs",
tags: ["audit-logs"],
})
.use(branchMiddleware)
.model(AuditLogModel)
// GET /api/audit-logs - Get audit logs (admin only)
.get(
"/",
async ({ query, currentBranchId, userId, userGroups }) => {
const {
entityType,
entityId,
userId: filterUserId,
action,
actionType,
startDate,
endDate,
limit,
offset,
} = query as {
entityType?: string;
entityId?: string;
userId?: string;
action?: string;
actionType?: string;
startDate?: string;
endDate?: string;
limit?: string;
offset?: string;
};
try {
const logs = await service.getAuditLogs(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: userGroups as string[],
},
{
entityType,
entityId,
userId: filterUserId,
action,
actionType,
startDate,
endDate,
limit: limit ? Number.parseInt(limit) : 100,
offset: offset ? Number.parseInt(offset) : 0,
},
);
return {
success: true,
data: logs,
count: logs.length,
message: `Found ${logs.length} audit log(s)`,
};
} catch (error) {
console.error("Error fetching audit logs:", error);
return {
success: false,
error: "Failed to fetch audit logs",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
query: t.Optional(
t.Object({
entityType: t.Optional(t.String()),
entityId: t.Optional(t.String()),
userId: t.Optional(t.String()),
action: t.Optional(t.String()),
actionType: t.Optional(t.String()),
startDate: t.Optional(t.String()),
endDate: t.Optional(t.String()),
limit: t.Optional(t.String()),
offset: t.Optional(t.String()),
}),
),
response: t.Union([
AuditLogModel.AuditLogList,
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get audit logs (admin only)",
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/audit-logs/stats - Get audit log statistics (admin only)
.get(
"/stats",
async ({ query, currentBranchId, userId, userGroups }) => {
const { entityType, startDate, endDate } = query as {
entityType?: string;
startDate?: string;
endDate?: string;
};
try {
const stats = await service.getAuditLogStats(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: userGroups as string[],
},
{
entityType,
startDate,
endDate,
},
);
return {
success: true,
data: stats,
message: "Statistics retrieved successfully",
};
} catch (error) {
console.error("Error fetching audit log statistics:", error);
return {
success: false,
error: "Failed to fetch audit log statistics",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
query: t.Optional(
t.Object({
entityType: t.Optional(t.String()),
startDate: t.Optional(t.String()),
endDate: t.Optional(t.String()),
}),
),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Any(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get audit log statistics (admin only)",
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/audit-logs/entity/:entityType/:entityId - Get logs by entity (admin only)
.get(
"/entity/:entityType/:entityId",
async ({ params, query, currentBranchId, userId, userGroups }) => {
const { entityType, entityId } = params;
const { limit, offset } = query as {
limit?: string;
offset?: string;
};
try {
const logs = await service.getAuditLogsByEntity(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: userGroups as string[],
},
entityType,
entityId,
{
limit: limit ? Number.parseInt(limit) : 100,
offset: offset ? Number.parseInt(offset) : 0,
},
);
return {
success: true,
data: logs,
count: logs.length,
message: `Found ${logs.length} audit log(s) for this entity`,
};
} catch (error) {
console.error("Error fetching audit logs by entity:", error);
return {
success: false,
error: "Failed to fetch audit logs by entity",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
entityType: t.String(),
entityId: t.String(),
}),
query: t.Optional(
t.Object({
limit: t.Optional(t.String()),
offset: t.Optional(t.String()),
}),
),
response: t.Union([
AuditLogModel.AuditLogList,
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get audit logs for a specific entity (admin only)",
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/audit-logs/user/:userId - Get logs by user (admin only)
.get(
"/user/:userId",
async ({ params, query, currentBranchId, userId, userGroups }) => {
const { userId: targetUserId } = params;
const { limit, offset } = query as {
limit?: string;
offset?: string;
};
try {
const logs = await service.getAuditLogsByUser(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: userGroups as string[],
},
targetUserId,
{
limit: limit ? Number.parseInt(limit) : 100,
offset: offset ? Number.parseInt(offset) : 0,
},
);
return {
success: true,
data: logs,
count: logs.length,
message: `Found ${logs.length} audit log(s) for this user`,
};
} catch (error) {
console.error("Error fetching audit logs by user:", error);
return {
success: false,
error: "Failed to fetch audit logs by user",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
userId: t.String(),
}),
query: t.Optional(
t.Object({
limit: t.Optional(t.String()),
offset: t.Optional(t.String()),
}),
),
response: t.Union([
AuditLogModel.AuditLogList,
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get audit logs for a specific user (admin only)",
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/audit-logs/:id - Get single audit log by ID (admin only)
.get(
"/:id",
async ({ params, currentBranchId, userId, userGroups }) => {
const { id } = params;
try {
const log = await service.getAuditLogById(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: userGroups as string[],
},
id,
);
if (!log) {
return {
success: false,
error: "Audit log not found or access denied",
};
}
return {
success: true,
data: log,
};
} catch (error) {
console.error("Error fetching audit log:", error);
return {
success: false,
error: "Failed to fetch audit log",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: AuditLogModel.AuditLog,
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get a single audit log by ID (admin only)",
security: [
{
BearerAuth: [],
},
],
},
},
);

View File

@@ -0,0 +1 @@
export { auditLogs } from "./controller";

View File

@@ -0,0 +1,30 @@
import { t } from "elysia";
export const AuditLogModel = {
// Audit log object
AuditLog: t.Object({
id: t.String(),
branchId: t.Optional(t.String()),
userId: t.Optional(t.String()),
actorId: t.Optional(t.String()),
entityType: t.String(),
entityId: t.String(),
action: t.String(),
actionType: t.Optional(t.String()),
beforeData: t.Optional(t.Any()),
afterData: t.Optional(t.Any()),
ipAddress: t.Optional(t.String()),
userAgent: t.Optional(t.String()),
requestId: t.Optional(t.String()),
createdAt: t.String(),
deletedAt: t.Optional(t.String()),
}),
// Audit log list response
AuditLogList: t.Object({
success: t.Literal(true),
data: t.Array(t.Any()),
count: t.Number(),
message: t.String(),
}),
};

View File

@@ -0,0 +1,256 @@
import { db } from "@/database/db";
import { auditLogs } from "@/database/schema";
import { eq, and, isNull, desc, or, like } from "drizzle-orm";
// Context type
export type Context = {
currentBranchId: string;
userId: string;
currentBranchCode: string;
accessibleBranches: string[];
userGroups: string[];
};
// Check if user has admin access
function checkAdminAccess(context: Context) {
const { userGroups } = context;
const adminGroups = ["admin", "superadmin", "auditor"];
const hasAccess = userGroups.some((group) => adminGroups.includes(group));
if (!hasAccess) {
throw new Error("Access denied. Admin access required");
}
}
// Get audit logs for current branch (admin only)
export async function getAuditLogs(
context: Context,
filters?: {
entityType?: string;
entityId?: string;
userId?: string;
action?: string;
actionType?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
},
) {
checkAdminAccess(context);
const { currentBranchId, userId } = context;
const {
entityType,
entityId,
userId: filterUserId,
action,
actionType,
startDate,
endDate,
limit = 100,
offset = 0,
} = filters || {};
const conditions = [isNull(auditLogs.deletedAt)];
// Admin can see all logs in their branch
if (currentBranchId) {
conditions.push(eq(auditLogs.branchId, currentBranchId));
}
if (entityType) {
conditions.push(eq(auditLogs.entityType, entityType));
}
if (entityId) {
conditions.push(eq(auditLogs.entityId, entityId));
}
if (filterUserId) {
conditions.push(eq(auditLogs.userId, filterUserId));
}
if (action) {
conditions.push(like(auditLogs.action, `%${action}%`));
}
if (actionType) {
conditions.push(eq(auditLogs.actionType, actionType));
}
if (startDate) {
conditions.push(
// @ts-ignore - date comparison
or(
// @ts-ignore
eq(auditLogs.createdAt, startDate),
// @ts-ignore
auditLogs.createdAt >= startDate,
),
);
}
if (endDate) {
// @ts-ignore
conditions.push(auditLogs.createdAt <= endDate);
}
const logs = await db
.select()
.from(auditLogs)
.where(and(...conditions))
.orderBy(desc(auditLogs.createdAt))
.limit(limit)
.offset(offset);
return logs;
}
// Get audit log by ID (admin only)
export async function getAuditLogById(context: Context, id: string) {
checkAdminAccess(context);
const { currentBranchId } = context;
const log = await db
.select()
.from(auditLogs)
.where(
and(
eq(auditLogs.id, id),
isNull(auditLogs.deletedAt),
eq(auditLogs.branchId, currentBranchId),
),
)
.limit(1);
return log[0] || null;
}
// Get audit logs by entity (admin only)
export async function getAuditLogsByEntity(
context: Context,
entityType: string,
entityId: string,
filters?: {
limit?: number;
offset?: number;
},
) {
checkAdminAccess(context);
const { currentBranchId } = context;
const { limit = 100, offset = 0 } = filters || {};
const logs = await db
.select()
.from(auditLogs)
.where(
and(
eq(auditLogs.branchId, currentBranchId),
eq(auditLogs.entityType, entityType),
eq(auditLogs.entityId, entityId),
isNull(auditLogs.deletedAt),
),
)
.orderBy(desc(auditLogs.createdAt))
.limit(limit)
.offset(offset);
return logs;
}
// Get audit logs by user (admin only)
export async function getAuditLogsByUser(
context: Context,
userId: string,
filters?: {
limit?: number;
offset?: number;
},
) {
checkAdminAccess(context);
const { currentBranchId } = context;
const { limit = 100, offset = 0 } = filters || {};
const logs = await db
.select()
.from(auditLogs)
.where(
and(
eq(auditLogs.branchId, currentBranchId),
eq(auditLogs.userId, userId),
isNull(auditLogs.deletedAt),
),
)
.orderBy(desc(auditLogs.createdAt))
.limit(limit)
.offset(offset);
return logs;
}
// Get audit log statistics (admin only)
export async function getAuditLogStats(
context: Context,
filters?: {
entityType?: string;
startDate?: string;
endDate?: string;
},
) {
checkAdminAccess(context);
const { currentBranchId } = context;
const { entityType, startDate, endDate } = filters || {};
const conditions = [
isNull(auditLogs.deletedAt),
eq(auditLogs.branchId, currentBranchId),
];
if (entityType) {
conditions.push(eq(auditLogs.entityType, entityType));
}
if (startDate) {
// @ts-ignore
conditions.push(auditLogs.createdAt >= startDate);
}
if (endDate) {
// @ts-ignore
conditions.push(auditLogs.createdAt <= endDate);
}
const logs = await db
.select()
.from(auditLogs)
.where(and(...conditions));
// Calculate statistics
const stats = {
total: logs.length,
byAction: {} as Record<string, number>,
byEntityType: {} as Record<string, number>,
byUser: {} as Record<string, number>,
};
logs.forEach((log) => {
// By action
const action = log.action || "unknown";
stats.byAction[action] = (stats.byAction[action] || 0) + 1;
// By entity type
const entType = log.entityType || "unknown";
stats.byEntityType[entType] = (stats.byEntityType[entType] || 0) + 1;
// By user
const user = log.userId || "unknown";
stats.byUser[user] = (stats.byUser[user] || 0) + 1;
});
return stats;
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,35 +4,45 @@ import { t } from "elysia";
export const CustomerModel = {
Customer: t.Object({
id: t.String(),
branch: t.String(),
branchId: t.String(),
name: t.String(),
email: t.String({ format: "email" }),
phone: t.String(),
company: t.String(),
address: t.String(),
status: t.Union([
customerStatus: t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
customerType: t.Optional(t.String()),
taxId: t.Optional(t.String()),
crmCustomerCode: t.String(),
erpCustomerCode: t.Nullable(t.String()),
isActive: t.Boolean(),
createdBy: t.String(),
updatedBy: t.String(),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
deletedAt: t.Nullable(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(
customerStatus: t.Optional(
t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
),
customerType: t.Optional(t.String()),
taxId: t.Optional(t.String()),
erpCustomerCode: t.Optional(t.String()),
}),
UpdateCustomer: t.Object({
@@ -41,13 +51,14 @@ export const CustomerModel = {
phone: t.Optional(t.String()),
company: t.Optional(t.String()),
address: t.Optional(t.String()),
status: t.Optional(
customerStatus: t.Optional(
t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
),
erpCustomerCode: t.Optional(t.String()),
}),
CustomerList: t.Object({
@@ -55,17 +66,86 @@ export const CustomerModel = {
data: t.Array(
t.Object({
id: t.String(),
branch: t.String(),
branchId: t.String(),
name: t.String(),
email: t.String(),
phone: t.String(),
company: t.String(),
address: t.String(),
status: t.Union([
customerStatus: t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
customerType: t.Optional(t.String()),
taxId: t.Optional(t.String()),
crmCustomerCode: t.String(),
erpCustomerCode: t.Nullable(t.String()),
isActive: t.Boolean(),
createdAt: t.String(),
updatedAt: t.String(),
deletedAt: t.Nullable(t.String()),
}),
),
count: t.Number(),
message: t.Optional(t.String()),
}),
};
// Contact Models
export const ContactModel = {
Contact: t.Object({
id: t.String(),
customerId: t.String(),
name: t.String(),
position: t.Nullable(t.String()),
phone: t.Nullable(t.String()),
mobile: t.Nullable(t.String()),
email: t.Nullable(t.String()),
isPrimary: t.Nullable(t.Boolean()),
isPublic: t.Boolean(),
notes: t.Nullable(t.String()),
branchId: t.String(),
createdBy: t.String(),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
}),
CreateContact: t.Object({
name: t.String(),
position: t.Optional(t.String()),
phone: t.Optional(t.String()),
mobile: t.Optional(t.String()),
email: t.Optional(t.String()),
isPrimary: t.Optional(t.Boolean()),
notes: t.Optional(t.String()),
}),
UpdateContact: t.Object({
name: t.Optional(t.String()),
position: t.Optional(t.String()),
phone: t.Optional(t.String()),
mobile: t.Optional(t.String()),
email: t.Optional(t.String()),
isPrimary: t.Optional(t.Boolean()),
isPublic: t.Optional(t.Boolean()),
notes: t.Optional(t.String()),
}),
ContactList: t.Object({
success: t.Boolean(),
data: t.Array(
t.Object({
id: t.String(),
customerId: t.String(),
name: t.String(),
position: t.Nullable(t.String()),
phone: t.Nullable(t.String()),
mobile: t.Nullable(t.String()),
email: t.Nullable(t.String()),
isPrimary: t.Nullable(t.Boolean()),
isPublic: t.Boolean(),
notes: t.Nullable(t.String()),
createdAt: t.String(),
updatedAt: t.String(),
}),
@@ -75,8 +155,51 @@ export const CustomerModel = {
}),
};
// Contact Share Models
export const ContactShareModel = {
ContactShare: t.Object({
id: t.String(),
contactId: t.String(),
sharedWithUserId: t.String(),
sharedBy: t.String(),
sharedAt: t.String({ format: "date-time" }),
notes: t.Nullable(t.String()),
}),
ShareContactRequest: t.Object({
targetUserId: t.String(),
notes: t.Optional(t.String()),
}),
ContactShareList: t.Object({
success: t.Boolean(),
data: t.Array(
t.Object({
id: t.String(),
contactId: t.String(),
sharedWithUserId: t.String(),
sharedBy: t.String(),
sharedAt: t.String({ format: "date-time" }),
notes: t.Nullable(t.String()),
}),
),
count: t.Number(),
message: 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;
export type Contact = typeof ContactModel.Contact.static;
export type CreateContact = typeof ContactModel.CreateContact.static;
export type UpdateContact = typeof ContactModel.UpdateContact.static;
export type ContactList = typeof ContactModel.ContactList.static;
export type ContactShare = typeof ContactShareModel.ContactShare.static;
export type ShareContactRequest =
typeof ContactShareModel.ShareContactRequest.static;
export type ContactShareList = typeof ContactShareModel.ContactShareList.static;

View File

@@ -1,109 +1,690 @@
import { getCustomersByBranch, getCustomerById } from "@/lib/mock-data";
import type { Customer, CreateCustomer, UpdateCustomer } from "./model";
import { db } from "@/database/db";
import {
customers,
customerContacts,
customerContactShares,
type Customer,
type NewCustomer,
type CustomerContact,
type NewCustomerContact,
type CustomerContactShare,
type NewCustomerContactShare,
} from "@/database/schema";
import { eq, and, or, sql, exists } from "drizzle-orm";
import { BranchContext } from "@/middleware/branch";
/**
* Get all customers for a specific branch
* @param branch - Branch identifier
* Customer Service
* Handles customer operations with branch scoping and contact visibility
*/
// =========================================================
// CUSTOMER OPERATIONS
// =========================================================
/**
* Get all customers for the current branch
* @param context - Branch context from middleware
* @param status - Optional status filter
* @returns Array of customers
*/
export function getAllCustomers(
branch: string,
status?: "active" | "inactive" | "pending",
): Customer[] {
let customers = getCustomersByBranch(branch);
export async function getCustomersByBranch(
context: BranchContext,
status?: string,
): Promise<Customer[]> {
const { currentBranchId } = context;
if (status) {
customers = customers.filter((customer) => customer.status === status);
return await db
.select()
.from(customers)
.where(
and(
eq(customers.branchId, currentBranchId),
eq(customers.customerStatus, status),
),
);
}
return customers;
return await db
.select()
.from(customers)
.where(eq(customers.branchId, currentBranchId));
}
/**
* Get a single customer by ID and branch
* @param branch - Branch identifier
* @param id - Customer ID
* @returns Customer or undefined if not found
* Get a single customer by ID (with branch validation)
* @param context - Branch context from middleware
* @param customerId - Customer ID
* @returns Customer or null if not found or unauthorized
*/
export function getCustomerByIdAndBranch(
branch: string,
id: string,
): Customer | undefined {
const customer = getCustomerById(id);
export async function getCustomerById(
context: BranchContext,
customerId: string,
): Promise<Customer | null> {
const { currentBranchId } = context;
// Only return if customer belongs to the specified branch
if (customer && customer.branch === branch) {
return customer;
}
const [customer] = await db
.select()
.from(customers)
.where(
and(
eq(customers.id, customerId),
eq(customers.branchId, currentBranchId),
),
)
.limit(1);
return undefined;
return customer || null;
}
/**
* Create a new customer
* @param context - Branch context from middleware
* @param data - Customer creation data
* @returns Newly created customer
*/
export function createCustomer(data: CreateCustomer): Customer {
const newCustomer: Customer = {
id: `cust-${Date.now()}`,
export async function createCustomer(
context: BranchContext,
data: Omit<NewCustomer, "branchId" | "createdBy" | "updatedBy">,
): Promise<Customer> {
const { currentBranchId, userId } = context;
const newCustomer: NewCustomer = {
...data,
status: data.status || "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
branchId: currentBranchId,
createdBy: userId,
updatedBy: userId,
};
// In a real app, this would save to database
// For now, we'll just return the new customer
return newCustomer;
const [created] = await db.insert(customers).values(newCustomer).returning();
return created;
}
/**
* Update an existing customer
* @param branch - Branch identifier
* @param id - Customer ID
* @param context - Branch context from middleware
* @param customerId - Customer ID
* @param data - Customer update data
* @returns Updated customer or undefined if not found
* @returns Updated customer or null if not found
*/
export function updateCustomer(
branch: string,
id: string,
data: UpdateCustomer,
): Customer | undefined {
const customer = getCustomerByIdAndBranch(branch, id);
export async function updateCustomer(
context: BranchContext,
customerId: string,
data: Partial<NewCustomer>,
): Promise<Customer | null> {
const { currentBranchId, userId } = context;
if (!customer) {
return undefined;
// First, verify customer exists and belongs to branch
const existing = await getCustomerById(context, customerId);
if (!existing) {
return null;
}
// Merge update data
const updatedCustomer: Customer = {
...customer,
...data,
updatedAt: new Date().toISOString(),
};
const [updated] = await db
.update(customers)
.set({
...data,
updatedBy: userId,
updatedAt: new Date(),
})
.where(
and(
eq(customers.id, customerId),
eq(customers.branchId, currentBranchId),
),
)
.returning();
// In a real app, this would update database
return updatedCustomer;
return updated;
}
/**
* Delete a customer
* @param branch - Branch identifier
* @param id - Customer ID
* @returns Deleted customer or undefined if not found
* Soft delete a customer
* @param context - Branch context from middleware
* @param customerId - Customer ID
* @returns Deleted customer or null if not found
*/
export function deleteCustomer(
branch: string,
id: string,
): Customer | undefined {
const customer = getCustomerByIdAndBranch(branch, id);
export async function deleteCustomer(
context: BranchContext,
customerId: string,
): Promise<Customer | null> {
const { currentBranchId, userId } = context;
const [deleted] = await db
.update(customers)
.set({
deletedAt: new Date(),
updatedBy: userId,
})
.where(
and(
eq(customers.id, customerId),
eq(customers.branchId, currentBranchId),
),
)
.returning();
return deleted;
}
// =========================================================
// CONTACT OPERATIONS (WITH VISIBILITY LOGIC)
// =========================================================
/**
* Get visible contacts for a customer
* Visibility rules:
* - User can see contact if: createdBy == currentUser OR isPublic == true OR exists in contact_shares
*
* @param context - Branch context from middleware
* @param customerId - Customer ID
* @returns Array of visible contacts
*/
export async function getVisibleContactsForCustomer(
context: BranchContext,
customerId: string,
): Promise<CustomerContact[]> {
const { currentBranchId, userId } = context;
// Verify customer exists and belongs to branch
const customer = await getCustomerById(context, customerId);
if (!customer) {
return undefined;
return [];
}
// In a real app, this would delete from database
return customer;
// Get contacts where:
// 1. Customer matches AND branch matches AND (created by user OR is public OR shared with user)
const contacts = await db
.select()
.from(customerContacts)
.where(
and(
eq(customerContacts.customerId, customerId),
eq(customerContacts.branchId, currentBranchId),
or(
eq(customerContacts.createdBy, userId),
eq(customerContacts.isPublic, true),
exists(
db
.select({ id: customerContactShares.id })
.from(customerContactShares)
.where(
and(
eq(customerContactShares.contactId, customerContacts.id),
eq(customerContactShares.sharedWithUserId, userId),
),
),
),
),
),
);
return contacts;
}
/**
* Get all contacts for a customer (regardless of visibility)
* Only accessible to users with admin/manager roles
* @param context - Branch context from middleware
* @param customerId - Customer ID
* @returns Array of all contacts
*/
export async function getAllContactsForCustomer(
context: BranchContext,
customerId: string,
): Promise<CustomerContact[]> {
const { currentBranchId } = context;
// Verify customer exists and belongs to branch
const customer = await getCustomerById(context, customerId);
if (!customer) {
return [];
}
const contacts = await db
.select()
.from(customerContacts)
.where(
and(
eq(customerContacts.customerId, customerId),
eq(customerContacts.branchId, currentBranchId),
),
);
return contacts;
}
/**
* Get a specific contact by ID
* Enforces visibility rules
* Visibility: createdBy == userId OR isPublic == true OR exists in contact_shares
* @param context - Branch context from middleware
* @param contactId - Contact ID
* @returns Contact or null if not found or unauthorized
*/
export async function getContactById(
context: BranchContext,
contactId: string,
): Promise<CustomerContact | null> {
const { currentBranchId, userId } = context;
const [contact] = await db
.select()
.from(customerContacts)
.where(
and(
eq(customerContacts.id, contactId),
eq(customerContacts.branchId, currentBranchId),
or(
eq(customerContacts.createdBy, userId),
eq(customerContacts.isPublic, true),
exists(
db
.select({ id: customerContactShares.id })
.from(customerContactShares)
.where(
and(
eq(customerContactShares.contactId, customerContacts.id),
eq(customerContactShares.sharedWithUserId, userId),
),
),
),
),
),
)
.limit(1);
return contact || null;
}
/**
* Create a new contact
* @param context - Branch context from middleware
* @param customerId - Customer ID
* @param data - Contact creation data
* @returns Newly created contact
*/
export async function createContact(
context: BranchContext,
customerId: string,
data: Omit<
NewCustomerContact,
"branchId" | "customerId" | "createdBy" | "updatedBy"
>,
): Promise<CustomerContact | null> {
const { currentBranchId, userId } = context;
// Verify customer exists and belongs to branch
const customer = await getCustomerById(context, customerId);
if (!customer) {
return null;
}
const newContact: NewCustomerContact = {
...data,
branchId: currentBranchId,
customerId,
createdBy: userId,
updatedBy: userId,
};
const [created] = await db
.insert(customerContacts)
.values(newContact)
.returning();
return created;
}
/**
* Update a contact
* Only creator can update their own contacts
* @param context - Branch context from middleware
* @param contactId - Contact ID
* @param data - Contact update data
* @returns Updated contact or null if not found or unauthorized
*/
export async function updateContact(
context: BranchContext,
contactId: string,
data: Partial<NewCustomerContact>,
): Promise<CustomerContact | null> {
const { currentBranchId, userId } = context;
// Verify contact exists, belongs to branch, and was created by user
const existing = await getContactById(context, contactId);
if (!existing || existing.createdBy !== userId) {
return null;
}
const [updated] = await db
.update(customerContacts)
.set({
...data,
updatedBy: userId,
updatedAt: new Date(),
})
.where(
and(
eq(customerContacts.id, contactId),
eq(customerContacts.branchId, currentBranchId),
),
)
.returning();
return updated;
}
/**
* Share a contact with other users (make it public)
* Only creator can share their contacts
* @param context - Branch context from middleware
* @param contactId - Contact ID
* @returns Updated contact or null if not found or unauthorized
*/
export async function shareContact(
context: BranchContext,
contactId: string,
): Promise<CustomerContact | null> {
return updateContact(context, contactId, { isPublic: true });
}
/**
* Unshare a contact (make it private)
* Only creator can unshare their contacts
* @param context - Branch context from middleware
* @param contactId - Contact ID
* @returns Updated contact or null if not found or unauthorized
*/
export async function unshareContact(
context: BranchContext,
contactId: string,
): Promise<CustomerContact | null> {
return updateContact(context, contactId, { isPublic: false });
}
/**
* Delete a contact
* Only creator can delete their own contacts
* @param context - Branch context from middleware
* @param contactId - Contact ID
* @returns Deleted contact or null if not found or unauthorized
*/
export async function deleteContact(
context: BranchContext,
contactId: string,
): Promise<CustomerContact | null> {
const { currentBranchId, userId } = context;
// Verify contact exists, belongs to branch, and was created by user
const existing = await getContactById(context, contactId);
if (!existing || existing.createdBy !== userId) {
return null;
}
const [deleted] = await db
.delete(customerContacts)
.where(
and(
eq(customerContacts.id, contactId),
eq(customerContacts.branchId, currentBranchId),
),
)
.returning();
return deleted;
}
// =========================================================
// CONTACT SHARING OPERATIONS (SPECIFIC USER SHARING)
// =========================================================
/**
* Share a contact with a specific user
* Only creator can share their contacts
*
* @param context - Branch context from middleware
* @param contactId - Contact ID
* @param targetUserId - User ID to share with
* @param notes - Optional notes about the share
* @returns Created share record or null if not found or unauthorized
*/
export async function shareContactWithUser(
context: BranchContext,
contactId: string,
targetUserId: string,
notes?: string,
): Promise<CustomerContactShare | null> {
const { currentBranchId, userId } = context;
// Verify contact exists, belongs to branch, and was created by current user
const existing = await db
.select()
.from(customerContacts)
.where(
and(
eq(customerContacts.id, contactId),
eq(customerContacts.branchId, currentBranchId),
eq(customerContacts.createdBy, userId),
),
)
.limit(1);
if (!existing[0]) {
throw new Error(
"Contact not found or you don't have permission to share it",
);
}
// Prevent sharing with yourself
if (targetUserId === userId) {
throw new Error("Cannot share contact with yourself");
}
try {
const newShare: NewCustomerContactShare = {
contactId,
sharedWithUserId: targetUserId,
sharedBy: userId,
notes,
};
const [created] = await db
.insert(customerContactShares)
.values(newShare)
.returning();
return created;
} catch (error) {
// Handle unique constraint violation (already shared)
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code === "23505"
) {
throw new Error("Contact is already shared with this user");
}
throw error;
}
}
/**
* Unshare a contact from a specific user
* Only creator can unshare their contacts
*
* @param context - Branch context from middleware
* @param contactId - Contact ID
* @param targetUserId - User ID to unshare from
* @returns Deleted share record or null if not found
*/
export async function unshareContactFromUser(
context: BranchContext,
contactId: string,
targetUserId: string,
): Promise<CustomerContactShare | null> {
const { currentBranchId, userId } = context;
// Verify contact exists, belongs to branch, and was created by current user
const existing = await db
.select()
.from(customerContacts)
.where(
and(
eq(customerContacts.id, contactId),
eq(customerContacts.branchId, currentBranchId),
eq(customerContacts.createdBy, userId),
),
)
.limit(1);
if (!existing[0]) {
throw new Error(
"Contact not found or you don't have permission to unshare it",
);
}
const [deleted] = await db
.delete(customerContactShares)
.where(
and(
eq(customerContactShares.contactId, contactId),
eq(customerContactShares.sharedWithUserId, targetUserId),
),
)
.returning();
if (!deleted) {
throw new Error("Share not found");
}
return deleted;
}
/**
* Get all shares for a contact
* Only creator can view shares of their contacts
*
* @param context - Branch context from middleware
* @param contactId - Contact ID
* @returns Array of share records
*/
export async function getContactShares(
context: BranchContext,
contactId: string,
): Promise<CustomerContactShare[]> {
const { currentBranchId, userId } = context;
// Verify contact exists, belongs to branch, and was created by current user
const existing = await db
.select()
.from(customerContacts)
.where(
and(
eq(customerContacts.id, contactId),
eq(customerContacts.branchId, currentBranchId),
eq(customerContacts.createdBy, userId),
),
)
.limit(1);
if (!existing[0]) {
throw new Error(
"Contact not found or you don't have permission to view shares",
);
}
const shares = await db
.select()
.from(customerContactShares)
.where(eq(customerContactShares.contactId, contactId));
return shares;
}
/**
* Get all contacts shared with the current user
*
* @param context - Branch context from middleware
* @param customerId - Optional customer ID to filter
* @returns Array of contacts shared with the user
*/
export async function getContactsSharedWithMe(
context: BranchContext,
customerId?: string,
): Promise<CustomerContact[]> {
const { currentBranchId, userId } = context;
// Build conditions
const baseConditions = [
eq(customerContacts.branchId, currentBranchId),
exists(
db
.select({ id: customerContactShares.id })
.from(customerContactShares)
.where(
and(
eq(customerContactShares.contactId, customerContacts.id),
eq(customerContactShares.sharedWithUserId, userId),
),
),
),
];
// Add optional customer filter
const conditions = customerId
? [...baseConditions, eq(customerContacts.customerId, customerId)]
: baseConditions;
return await db
.select()
.from(customerContacts)
.where(and(...conditions));
}
// =========================================================
// BUSINESS RULE VALIDATIONS
// =========================================================
/**
* Check if user can create quotation for customer
* Business rule: User must have at least one visible contact for the customer
*
* @param context - Branch context from middleware
* @param customerId - Customer ID
* @returns True if user can create quotation
*/
export async function canCreateQuotationForCustomer(
context: BranchContext,
customerId: string,
): Promise<boolean> {
const contacts = await getVisibleContactsForCustomer(context, customerId);
return contacts.length > 0;
}
/**
* Generate unique CRM customer code
* Format: CUST-YYYY-MM-XXXXX
* @param branchCode - Branch code (e.g., "alla", "onvalla")
* @returns Unique customer code
*/
export async function generateCrmCustomerCode(
branchCode: string,
): Promise<string> {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
// Get count of customers this month for this branch
const [{ count }] = await db
.select({
count: sql<number>`count(*)`,
})
.from(customers)
.where(sql`to_char(${customers.createdAt}, 'YYYY-MM') = ${year}-${month}`);
const sequence = String(Number(count) + 1).padStart(5, "0");
return `CUST-${year}-${month}-${sequence}`;
}

View File

@@ -0,0 +1,434 @@
import { Elysia, t } from "elysia";
import * as service from "./service";
import { IndustrialEstateModel } from "./model";
import { branchMiddleware } from "@/middleware/branch";
// Create Elysia instance for industrial estates module
export const industrialEstates = new Elysia({
prefix: "/industrial-estates",
tags: ["industrial-estates"],
})
.use(branchMiddleware)
.model(IndustrialEstateModel)
// GET /api/industrial-estates - Get all industrial estates for current branch
.get(
"/",
async ({ query, currentBranchId, userId }) => {
const { locationId, isActive, search } = query as {
locationId?: string;
isActive?: string;
search?: string;
};
try {
const estates = await service.getIndustrialEstatesByBranch(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
{
locationId,
isActive:
isActive === "true"
? true
: isActive === "false"
? false
: undefined,
search,
},
);
return {
success: true,
data: estates,
count: estates.length,
message: `Found ${estates.length} industrial estate(s)`,
};
} catch (error) {
console.error("Error fetching industrial estates:", error);
return {
success: false,
error: "Failed to fetch industrial estates",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
query: t.Optional(
t.Object({
locationId: t.Optional(t.String()),
isActive: t.Optional(
t.Union([t.Literal("true"), t.Literal("false")]),
),
search: t.Optional(t.String()),
}),
),
response: t.Union([
IndustrialEstateModel.IndustrialEstateList,
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get all industrial estates for the current branch",
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/industrial-estates/location/:locationId - Get estates by location
.get(
"/location/:locationId",
async ({ params, currentBranchId, userId }) => {
const { locationId } = params;
try {
const estates = await service.getIndustrialEstatesByLocation(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
locationId,
);
return {
success: true,
data: estates,
count: estates.length,
message: `Found ${estates.length} industrial estate(s) for location`,
};
} catch (error) {
console.error("Error fetching industrial estates by location:", error);
return {
success: false,
error: "Failed to fetch industrial estates by location",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
locationId: t.String(),
}),
response: t.Union([
IndustrialEstateModel.IndustrialEstateList,
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get all industrial estates for a specific location",
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/industrial-estates/:id - Get single industrial estate by ID
.get(
"/:id",
async ({ params, currentBranchId, userId }) => {
const { id } = params;
try {
const estate = await service.getIndustrialEstateById(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
id,
);
if (!estate) {
return {
success: false,
error: "Industrial estate not found or access denied",
};
}
return {
success: true,
data: estate,
};
} catch (error) {
console.error("Error fetching industrial estate:", error);
return {
success: false,
error: "Failed to fetch industrial estate",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: IndustrialEstateModel.IndustrialEstate,
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get a single industrial estate by ID",
security: [
{
BearerAuth: [],
},
],
},
},
)
// POST /api/industrial-estates - Create new industrial estate
.post(
"/",
async ({ body, currentBranchId, userId }) => {
try {
const estate = await service.createIndustrialEstate(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
body,
);
return {
success: true,
data: estate,
message: "Industrial estate created successfully",
};
} catch (error) {
console.error("Error creating industrial estate:", error);
return {
success: false,
error: "Failed to create industrial estate",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
body: IndustrialEstateModel.CreateIndustrialEstate,
response: t.Union([
t.Object({
success: t.Literal(true),
data: IndustrialEstateModel.IndustrialEstate,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Create a new industrial estate",
security: [
{
BearerAuth: [],
},
],
},
},
)
// PUT /api/industrial-estates/:id - Update industrial estate
.put(
"/:id",
async ({ params, body, currentBranchId, userId }) => {
const { id } = params;
try {
const estate = await service.updateIndustrialEstate(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
id,
body,
);
return {
success: true,
data: estate,
message: "Industrial estate updated successfully",
};
} catch (error) {
console.error("Error updating industrial estate:", error);
return {
success: false,
error: "Failed to update industrial estate",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
id: t.String(),
}),
body: IndustrialEstateModel.UpdateIndustrialEstate,
response: t.Union([
t.Object({
success: t.Literal(true),
data: IndustrialEstateModel.IndustrialEstate,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Update an existing industrial estate",
security: [
{
BearerAuth: [],
},
],
},
},
)
// DELETE /api/industrial-estates/:id - Delete industrial estate
.delete(
"/:id",
async ({ params, currentBranchId, userId }) => {
const { id } = params;
try {
const estate = await service.deleteIndustrialEstate(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
id,
);
return {
success: true,
data: estate,
message: "Industrial estate deleted successfully",
};
} catch (error) {
console.error("Error deleting industrial estate:", error);
return {
success: false,
error: "Failed to delete industrial estate",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: IndustrialEstateModel.IndustrialEstate,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Delete an industrial estate",
security: [
{
BearerAuth: [],
},
],
},
},
)
// PATCH /api/industrial-estates/:id/toggle - Toggle active status
.patch(
"/:id/toggle",
async ({ params, currentBranchId, userId }) => {
const { id } = params;
try {
const estate = await service.toggleIndustrialEstateActive(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
id,
);
return {
success: true,
data: estate,
message: `Industrial estate ${estate.isActive ? "activated" : "deactivated"} successfully`,
};
} catch (error) {
console.error("Error toggling industrial estate:", error);
return {
success: false,
error: "Failed to toggle industrial estate",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: IndustrialEstateModel.IndustrialEstate,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Toggle active status of an industrial estate",
security: [
{
BearerAuth: [],
},
],
},
},
);

View File

@@ -0,0 +1,50 @@
import { t } from "elysia";
export const IndustrialEstateModel = {
// Industrial estate object
IndustrialEstate: t.Object({
id: t.String(),
branchId: t.String(),
code: t.String(),
nameTh: t.String(),
nameEn: t.Optional(t.String()),
locationId: t.String(),
latitude: t.Optional(t.Number()),
longitude: t.Optional(t.Number()),
isActive: t.Boolean(),
createdAt: t.String(),
updatedAt: t.String(),
createdBy: t.Optional(t.String()),
updatedBy: t.Optional(t.String()),
}),
// Create industrial estate
CreateIndustrialEstate: t.Object({
code: t.String({ minLength: 1 }),
nameTh: t.String({ minLength: 1 }),
nameEn: t.Optional(t.String()),
locationId: t.String(),
latitude: t.Optional(t.Number()),
longitude: t.Optional(t.Number()),
isActive: t.Optional(t.Boolean()),
}),
// Update industrial estate
UpdateIndustrialEstate: t.Object({
code: t.Optional(t.String({ minLength: 1 })),
nameTh: t.Optional(t.String({ minLength: 1 })),
nameEn: t.Optional(t.String()),
locationId: t.Optional(t.String()),
latitude: t.Optional(t.Number()),
longitude: t.Optional(t.Number()),
isActive: t.Optional(t.Boolean()),
}),
// Industrial estate list response
IndustrialEstateList: t.Object({
success: t.Literal(true),
data: t.Array(t.Any()),
count: t.Number(),
message: t.String(),
}),
};

View File

@@ -0,0 +1,294 @@
import { db } from "@/database/db";
import { industrialEstates } from "@/database/schema";
import { locations } from "@/database/schema";
import { eq, and, isNull, asc, like } from "drizzle-orm";
// Context type
export type Context = {
currentBranchId: string;
userId: string;
currentBranchCode: string;
accessibleBranches: string[];
userGroups: string[];
};
// Get all industrial estates for current branch
export async function getIndustrialEstatesByBranch(
context: Context,
filters?: {
locationId?: string;
isActive?: boolean;
search?: string;
},
) {
const { currentBranchId } = context;
const { locationId, isActive, search } = filters || {};
const conditions = [eq(industrialEstates.branchId, currentBranchId)];
if (locationId) {
conditions.push(eq(industrialEstates.locationId, locationId));
}
if (isActive !== undefined) {
conditions.push(eq(industrialEstates.isActive, isActive));
}
if (search) {
conditions.push(like(industrialEstates.nameTh, `%${search}%`));
}
const estates = await db
.select()
.from(industrialEstates)
.where(and(...conditions))
.orderBy(asc(industrialEstates.code), asc(industrialEstates.id));
return estates;
}
// Get industrial estate by ID
export async function getIndustrialEstateById(context: Context, id: string) {
const { currentBranchId } = context;
const estate = await db
.select()
.from(industrialEstates)
.where(
and(
eq(industrialEstates.id, id),
eq(industrialEstates.branchId, currentBranchId),
),
)
.limit(1);
return estate[0] || null;
}
// Get industrial estate with location details
export async function getIndustrialEstateWithLocation(
context: Context,
id: string,
) {
const { currentBranchId } = context;
const estate = await db
.select({
estate: industrialEstates,
location: locations,
})
.from(industrialEstates)
.innerJoin(locations, eq(industrialEstates.locationId, locations.id))
.where(
and(
eq(industrialEstates.id, id),
eq(industrialEstates.branchId, currentBranchId),
),
)
.limit(1);
return estate[0] || null;
}
// Get industrial estates by location
export async function getIndustrialEstatesByLocation(
context: Context,
locationId: string,
) {
const { currentBranchId } = context;
const estates = await db
.select()
.from(industrialEstates)
.where(
and(
eq(industrialEstates.branchId, currentBranchId),
eq(industrialEstates.locationId, locationId),
),
)
.orderBy(asc(industrialEstates.code), asc(industrialEstates.nameTh));
return estates;
}
// Create industrial estate
export async function createIndustrialEstate(
context: Context,
data: {
code: string;
nameTh: string;
nameEn?: string;
locationId: string;
latitude?: number;
longitude?: number;
isActive?: boolean;
},
) {
const { currentBranchId, userId } = context;
// Check if code already exists in this branch
const existing = await db
.select()
.from(industrialEstates)
.where(
and(
eq(industrialEstates.branchId, currentBranchId),
eq(industrialEstates.code, data.code),
),
)
.limit(1);
if (existing.length > 0) {
throw new Error(
`Industrial estate with code "${data.code}" already exists in this branch`,
);
}
// Validate location exists and belongs to this branch
const location = await db
.select()
.from(locations)
.where(
and(
eq(locations.id, data.locationId),
eq(locations.branchId, currentBranchId),
),
)
.limit(1);
if (location.length === 0) {
throw new Error("Location not found or access denied");
}
const [estate] = await db
.insert(industrialEstates)
.values({
...data,
branchId: currentBranchId,
isActive: data.isActive ?? true,
createdBy: userId,
updatedBy: userId,
})
.returning();
return estate;
}
// Update industrial estate
export async function updateIndustrialEstate(
context: Context,
id: string,
data: {
code?: string;
nameTh?: string;
nameEn?: string;
locationId?: string;
latitude?: number;
longitude?: number;
isActive?: boolean;
},
) {
const { currentBranchId, userId } = context;
// Check if industrial estate exists
const existing = await getIndustrialEstateById(context, id);
if (!existing) {
throw new Error("Industrial estate not found or access denied");
}
// Check code uniqueness if changing code
if (data.code && data.code !== existing.code) {
const codeCheck = await db
.select()
.from(industrialEstates)
.where(
and(
eq(industrialEstates.branchId, currentBranchId),
eq(industrialEstates.code, data.code),
),
)
.limit(1);
if (codeCheck.length > 0) {
throw new Error(
`Industrial estate with code "${data.code}" already exists`,
);
}
}
// Validate location if changing
if (data.locationId && data.locationId !== existing.locationId) {
const location = await db
.select()
.from(locations)
.where(
and(
eq(locations.id, data.locationId),
eq(locations.branchId, currentBranchId),
),
)
.limit(1);
if (location.length === 0) {
throw new Error("Location not found or access denied");
}
}
const [updated] = await db
.update(industrialEstates)
.set({
...data,
updatedBy: userId,
updatedAt: new Date(),
})
.where(eq(industrialEstates.id, id))
.returning();
return updated;
}
// Delete industrial estate
export async function deleteIndustrialEstate(context: Context, id: string) {
const { userId } = context;
// Check if industrial estate exists
const existing = await getIndustrialEstateById(context, id);
if (!existing) {
throw new Error("Industrial estate not found or access denied");
}
// Check if industrial estate is used in other tables
// (This check would need to be added when those tables are implemented)
const [deleted] = await db
.delete(industrialEstates)
.where(eq(industrialEstates.id, id))
.returning();
return deleted;
}
// Toggle active status
export async function toggleIndustrialEstateActive(
context: Context,
id: string,
) {
const { userId } = context;
const existing = await getIndustrialEstateById(context, id);
if (!existing) {
throw new Error("Industrial estate not found or access denied");
}
const [toggled] = await db
.update(industrialEstates)
.set({
isActive: !existing.isActive,
updatedBy: userId,
updatedAt: new Date(),
})
.where(eq(industrialEstates.id, id))
.returning();
return toggled;
}

View File

@@ -0,0 +1,511 @@
import { Elysia, t } from "elysia";
import * as service from "./service";
import { LocationModel } from "./model";
import { branchMiddleware } from "@/middleware/branch";
// Create Elysia instance for locations module
export const locations = new Elysia({
prefix: "/locations",
tags: ["locations"],
})
.use(branchMiddleware)
.model(LocationModel)
// GET /api/locations - Get all locations for current branch
.get(
"/",
async ({ query, currentBranchId, userId }) => {
const { type, parentId, search } = query as {
type?: string;
parentId?: string;
search?: string;
};
try {
const locationList = await service.getLocationsByBranch(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
{ type, parentId, search },
);
return {
success: true,
data: locationList,
count: locationList.length,
message: `Found ${locationList.length} location(s)`,
};
} catch (error) {
console.error("Error fetching locations:", error);
return {
success: false,
error: "Failed to fetch locations",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
query: t.Optional(
t.Object({
type: t.Optional(t.String()),
parentId: t.Optional(t.String()),
search: t.Optional(t.String()),
}),
),
response: t.Union([
LocationModel.LocationList,
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get all locations for the current branch",
parameters: [
{
name: "type",
in: "query",
required: false,
schema: { type: "string" },
description:
"Filter by type (country, province, district, subdistrict)",
},
{
name: "parentId",
in: "query",
required: false,
schema: { type: "string" },
description: "Filter by parent location ID",
},
{
name: "search",
in: "query",
required: false,
schema: { type: "string" },
description: "Search in nameTh",
},
],
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/locations/tree - Get location tree
.get(
"/tree",
async ({ query, currentBranchId, userId }) => {
const { type } = query as { type?: string };
try {
const tree = await service.getLocationTree(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
type,
);
return {
success: true,
data: tree,
count: tree.length,
message: `Found ${tree.length} root location(s)`,
};
} catch (error) {
console.error("Error fetching location tree:", error);
return {
success: false,
error: "Failed to fetch location tree",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
query: t.Optional(
t.Object({
type: t.Optional(t.String()),
}),
),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Array(t.Any()),
count: t.Number(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get hierarchical location tree",
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/locations/type/:type - Get locations by type
.get(
"/type/:type",
async ({ params, currentBranchId, userId }) => {
const { type } = params;
try {
const locationList = await service.getLocationsByType(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
type,
);
return {
success: true,
data: locationList,
count: locationList.length,
message: `Found ${locationList.length} location(s) for type: ${type}`,
};
} catch (error) {
console.error("Error fetching locations by type:", error);
return {
success: false,
error: "Failed to fetch locations by type",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
type: t.String(),
}),
response: t.Union([
LocationModel.LocationList,
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get all locations for a specific type",
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/locations/:id/children - Get children locations
.get(
"/:id/children",
async ({ params, currentBranchId, userId }) => {
const { id } = params;
try {
const children = await service.getChildrenLocations(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
id,
);
return {
success: true,
data: children,
count: children.length,
message: `Found ${children.length} child location(s)`,
};
} catch (error) {
console.error("Error fetching children locations:", error);
return {
success: false,
error: "Failed to fetch children locations",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Array(t.Any()),
count: t.Number(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get all child locations of a parent",
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/locations/:id - Get single location by ID
.get(
"/:id",
async ({ params, currentBranchId, userId }) => {
const { id } = params;
try {
const location = await service.getLocationById(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
id,
);
if (!location) {
return {
success: false,
error: "Location not found or access denied",
};
}
return {
success: true,
data: location,
};
} catch (error) {
console.error("Error fetching location:", error);
return {
success: false,
error: "Failed to fetch location",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: LocationModel.Location,
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get a single location by ID",
security: [
{
BearerAuth: [],
},
],
},
},
)
// POST /api/locations - Create new location
.post(
"/",
async ({ body, currentBranchId, userId }) => {
try {
const location = await service.createLocation(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
body,
);
return {
success: true,
data: location,
message: "Location created successfully",
};
} catch (error) {
console.error("Error creating location:", error);
return {
success: false,
error: "Failed to create location",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
body: LocationModel.CreateLocation,
response: t.Union([
t.Object({
success: t.Literal(true),
data: LocationModel.Location,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Create a new location",
security: [
{
BearerAuth: [],
},
],
},
},
)
// PUT /api/locations/:id - Update location
.put(
"/:id",
async ({ params, body, currentBranchId, userId }) => {
const { id } = params;
try {
const location = await service.updateLocation(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
id,
body,
);
return {
success: true,
data: location,
message: "Location updated successfully",
};
} catch (error) {
console.error("Error updating location:", error);
return {
success: false,
error: "Failed to update location",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
id: t.String(),
}),
body: LocationModel.UpdateLocation,
response: t.Union([
t.Object({
success: t.Literal(true),
data: LocationModel.Location,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Update an existing location",
security: [
{
BearerAuth: [],
},
],
},
},
)
// DELETE /api/locations/:id - Delete location
.delete(
"/:id",
async ({ params, currentBranchId, userId }) => {
const { id } = params;
try {
const location = await service.deleteLocation(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
id,
);
return {
success: true,
data: location,
message: "Location deleted successfully",
};
} catch (error) {
console.error("Error deleting location:", error);
return {
success: false,
error: "Failed to delete location",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: LocationModel.Location,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Delete a location",
security: [
{
BearerAuth: [],
},
],
},
},
);

View File

@@ -0,0 +1,54 @@
import { t } from "elysia";
export const LocationModel = {
// Location object
Location: t.Object({
id: t.String(),
branchId: t.String(),
code: t.String(),
nameTh: t.String(),
nameEn: t.Optional(t.String()),
type: t.String(),
parentId: t.Optional(t.String()),
createdAt: t.String(),
updatedAt: t.String(),
}),
// Create location
CreateLocation: t.Object({
code: t.String({ minLength: 1 }),
nameTh: t.String({ minLength: 1 }),
nameEn: t.Optional(t.String()),
type: t.Union([
t.Literal("country"),
t.Literal("province"),
t.Literal("district"),
t.Literal("subdistrict"),
]),
parentId: t.Optional(t.String()),
}),
// Update location
UpdateLocation: t.Object({
code: t.Optional(t.String({ minLength: 1 })),
nameTh: t.Optional(t.String({ minLength: 1 })),
nameEn: t.Optional(t.String()),
type: t.Optional(
t.Union([
t.Literal("country"),
t.Literal("province"),
t.Literal("district"),
t.Literal("subdistrict"),
]),
),
parentId: t.Optional(t.String()),
}),
// Location list response
LocationList: t.Object({
success: t.Literal(true),
data: t.Array(t.Any()),
count: t.Number(),
message: t.String(),
}),
};

View File

@@ -0,0 +1,355 @@
import { db } from "@/database/db";
import { locations } from "@/database/schema";
import { eq, and, isNull, asc, like } from "drizzle-orm";
// Allowed location types
export const ALLOWED_TYPES = [
"country",
"province",
"district",
"subdistrict",
] as const;
export type LocationType = (typeof ALLOWED_TYPES)[number];
// Context type
export type Context = {
currentBranchId: string;
userId: string;
currentBranchCode: string;
accessibleBranches: string[];
userGroups: string[];
};
// Get all locations for current branch
export async function getLocationsByBranch(
context: Context,
filters?: {
type?: string;
parentId?: string;
search?: string;
},
) {
const { currentBranchId } = context;
const { type, parentId, search } = filters || {};
const conditions = [eq(locations.branchId, currentBranchId)];
if (type) {
conditions.push(eq(locations.type, type));
}
if (parentId !== undefined) {
if (parentId === "null") {
conditions.push(isNull(locations.parentId));
} else {
conditions.push(eq(locations.parentId, parentId));
}
}
if (search) {
conditions.push(like(locations.nameTh, `%${search}%`));
}
const allLocations = await db
.select()
.from(locations)
.where(and(...conditions))
.orderBy(asc(locations.code), asc(locations.id));
return allLocations;
}
// Get location by ID
export async function getLocationById(context: Context, id: string) {
const { currentBranchId } = context;
const location = await db
.select()
.from(locations)
.where(and(eq(locations.id, id), eq(locations.branchId, currentBranchId)))
.limit(1);
return location[0] || null;
}
// Get locations by type
export async function getLocationsByType(context: Context, type: string) {
const { currentBranchId } = context;
const locationList = await db
.select()
.from(locations)
.where(
and(eq(locations.branchId, currentBranchId), eq(locations.type, type)),
)
.orderBy(asc(locations.code), asc(locations.nameTh));
return locationList;
}
// Get children locations
export async function getChildrenLocations(context: Context, parentId: string) {
const { currentBranchId } = context;
const children = await db
.select()
.from(locations)
.where(
and(
eq(locations.branchId, currentBranchId),
eq(locations.parentId, parentId),
),
)
.orderBy(asc(locations.code), asc(locations.nameTh));
return children;
}
// Get location tree (hierarchical)
export async function getLocationTree(context: Context, type?: string) {
const { currentBranchId } = context;
const conditions = [eq(locations.branchId, currentBranchId)];
if (type) {
conditions.push(eq(locations.type, type));
}
const allLocations = await db
.select()
.from(locations)
.where(and(...conditions))
.orderBy(asc(locations.code));
// Build tree structure
const locationMap = new Map();
const roots: any[] = [];
allLocations.forEach((loc) => {
locationMap.set(loc.id, { ...loc, children: [] });
});
allLocations.forEach((loc) => {
const node = locationMap.get(loc.id);
if (loc.parentId && locationMap.has(loc.parentId)) {
locationMap.get(loc.parentId).children.push(node);
} else {
roots.push(node);
}
});
return roots;
}
// Create location
export async function createLocation(
context: Context,
data: {
code: string;
nameTh: string;
nameEn?: string;
type: string;
parentId?: string;
},
) {
const { currentBranchId } = context;
// Validate type
if (!ALLOWED_TYPES.includes(data.type as LocationType)) {
throw new Error(
`Invalid location type. Allowed types: ${ALLOWED_TYPES.join(", ")}`,
);
}
// Check if code already exists in this branch
const existing = await db
.select()
.from(locations)
.where(
and(
eq(locations.branchId, currentBranchId),
eq(locations.code, data.code),
),
)
.limit(1);
if (existing.length > 0) {
throw new Error(
`Location with code "${data.code}" already exists in this branch`,
);
}
// Validate parent if provided
if (data.parentId) {
const parent = await db
.select()
.from(locations)
.where(
and(
eq(locations.id, data.parentId),
eq(locations.branchId, currentBranchId),
),
)
.limit(1);
if (parent.length === 0) {
throw new Error("Parent location not found or access denied");
}
// Validate type hierarchy
const typeHierarchy: Record<string, string> = {
country: "province",
province: "district",
district: "subdistrict",
};
const expectedChildType = typeHierarchy[parent[0].type];
if (data.type !== expectedChildType) {
throw new Error(
`Invalid type hierarchy. ${parent[0].type} can only have ${expectedChildType} as child`,
);
}
} else if (data.type !== "country") {
throw new Error(`Locations of type "${data.type}" must have a parent`);
}
const [location] = await db
.insert(locations)
.values({
...data,
branchId: currentBranchId,
})
.returning();
return location;
}
// Update location
export async function updateLocation(
context: Context,
id: string,
data: {
code?: string;
nameTh?: string;
nameEn?: string;
type?: string;
parentId?: string;
},
) {
const { currentBranchId } = context;
// Check if location exists
const existing = await getLocationById(context, id);
if (!existing) {
throw new Error("Location not found or access denied");
}
// Validate type if provided
if (data.type && !ALLOWED_TYPES.includes(data.type as LocationType)) {
throw new Error(
`Invalid location type. Allowed types: ${ALLOWED_TYPES.join(", ")}`,
);
}
// Check code uniqueness if changing code
if (data.code && data.code !== existing.code) {
const codeCheck = await db
.select()
.from(locations)
.where(
and(
eq(locations.branchId, currentBranchId),
eq(locations.code, data.code),
),
)
.limit(1);
if (codeCheck.length > 0) {
throw new Error(`Location with code "${data.code}" already exists`);
}
}
// Validate parent if changing
if (data.parentId !== undefined && data.parentId !== existing.parentId) {
if (data.parentId) {
const parent = await db
.select()
.from(locations)
.where(
and(
eq(locations.id, data.parentId),
eq(locations.branchId, currentBranchId),
),
)
.limit(1);
if (parent.length === 0) {
throw new Error("Parent location not found or access denied");
}
// Check for circular reference
if (data.parentId === id) {
throw new Error("Cannot set location as its own parent");
}
// Validate type hierarchy
const typeHierarchy: Record<string, string> = {
country: "province",
province: "district",
district: "subdistrict",
};
const expectedChildType = typeHierarchy[parent[0].type];
const targetType = data.type || existing.type;
if (targetType !== expectedChildType) {
throw new Error(
`Invalid type hierarchy. ${parent[0].type} can only have ${expectedChildType} as child`,
);
}
} else if (existing.type !== "country") {
throw new Error(
`Locations of type "${existing.type}" must have a parent`,
);
}
}
const [updated] = await db
.update(locations)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(locations.id, id))
.returning();
return updated;
}
// Delete location
export async function deleteLocation(context: Context, id: string) {
// Check if location exists
const existing = await getLocationById(context, id);
if (!existing) {
throw new Error("Location not found or access denied");
}
// Check if this location has children
const children = await db
.select()
.from(locations)
.where(eq(locations.parentId, id))
.limit(1);
if (children.length > 0) {
throw new Error("Cannot delete location that has child locations");
}
// Check if location is used in industrial estates
// (This check would need to be added when industrial estates are implemented)
const [deleted] = await db
.delete(locations)
.where(eq(locations.id, id))
.returning();
return deleted;
}

View File

@@ -0,0 +1,503 @@
import { Elysia, t } from "elysia";
import * as service from "./service";
import { MasterOptionModel } from "./model";
import { branchMiddleware } from "@/middleware/branch";
// Create Elysia instance for master options module
export const masterOptions = new Elysia({
prefix: "/master-options",
tags: ["master-options"],
})
.use(branchMiddleware)
.model(MasterOptionModel)
// GET /api/master-options - Get all master options for current branch
.get(
"/",
async ({ query, currentBranchId, userId }) => {
const { category, isActive } = query as {
category?: string;
isActive?: string;
};
try {
const options = await service.getMasterOptionsByBranch(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
{
category,
isActive:
isActive === "true"
? true
: isActive === "false"
? false
: undefined,
},
);
return {
success: true,
data: options,
count: options.length,
message: `Found ${options.length} option(s)`,
};
} catch (error) {
console.error("Error fetching master options:", error);
return {
success: false,
error: "Failed to fetch master options",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
query: t.Optional(
t.Object({
category: t.Optional(t.String()),
isActive: t.Optional(
t.Union([t.Literal("true"), t.Literal("false")]),
),
}),
),
response: t.Union([
MasterOptionModel.MasterOptionList,
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get all master options for the current branch",
parameters: [
{
name: "category",
in: "query",
required: false,
schema: { type: "string" },
description: "Filter by category",
},
{
name: "isActive",
in: "query",
required: false,
schema: { type: "boolean" },
description: "Filter by active status",
},
],
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/master-options/categories - Get all categories
.get(
"/categories",
async ({ currentBranchId, userId }) => {
try {
const categories = await service.getCategories({
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
});
return {
success: true,
data: categories,
count: categories.length,
message: `Found ${categories.length} categor(ies)`,
};
} catch (error) {
console.error("Error fetching categories:", error);
return {
success: false,
error: "Failed to fetch categories",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Array(t.String()),
count: t.Number(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get all categories for master options",
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/master-options/category/:category - Get options by category
.get(
"/category/:category",
async ({ params, currentBranchId, userId }) => {
const { category } = params;
try {
const options = await service.getMasterOptionsByCategory(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
category,
);
return {
success: true,
data: options,
count: options.length,
message: `Found ${options.length} option(s) for category: ${category}`,
};
} catch (error) {
console.error("Error fetching options by category:", error);
return {
success: false,
error: "Failed to fetch options by category",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
category: t.String(),
}),
response: t.Union([
MasterOptionModel.MasterOptionList,
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get all active options for a specific category",
security: [
{
BearerAuth: [],
},
],
},
},
)
// GET /api/master-options/:id - Get single option by ID
.get(
"/:id",
async ({ params, currentBranchId, userId }) => {
const { id } = params;
try {
const option = await service.getMasterOptionById(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
Number.parseInt(id),
);
if (!option) {
return {
success: false,
error: "Master option not found or access denied",
};
}
return {
success: true,
data: option,
};
} catch (error) {
console.error("Error fetching master option:", error);
return {
success: false,
error: "Failed to fetch master option",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: MasterOptionModel.MasterOption,
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Get a single master option by ID",
security: [
{
BearerAuth: [],
},
],
},
},
)
// POST /api/master-options - Create new master option
.post(
"/",
async ({ body, currentBranchId, userId }) => {
try {
const option = await service.createMasterOption(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
body,
);
return {
success: true,
data: option,
message: "Master option created successfully",
};
} catch (error) {
console.error("Error creating master option:", error);
return {
success: false,
error: "Failed to create master option",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
body: MasterOptionModel.CreateMasterOption,
response: t.Union([
t.Object({
success: t.Literal(true),
data: MasterOptionModel.MasterOption,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Create a new master option",
security: [
{
BearerAuth: [],
},
],
},
},
)
// PUT /api/master-options/:id - Update master option
.put(
"/:id",
async ({ params, body, currentBranchId, userId }) => {
const { id } = params;
try {
const option = await service.updateMasterOption(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
Number.parseInt(id),
body,
);
return {
success: true,
data: option,
message: "Master option updated successfully",
};
} catch (error) {
console.error("Error updating master option:", error);
return {
success: false,
error: "Failed to update master option",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
id: t.String(),
}),
body: MasterOptionModel.UpdateMasterOption,
response: t.Union([
t.Object({
success: t.Literal(true),
data: MasterOptionModel.MasterOption,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Update an existing master option",
security: [
{
BearerAuth: [],
},
],
},
},
)
// DELETE /api/master-options/:id - Delete master option (soft delete)
.delete(
"/:id",
async ({ params, currentBranchId, userId }) => {
const { id } = params;
try {
const option = await service.deleteMasterOption(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
Number.parseInt(id),
);
return {
success: true,
data: option,
message: "Master option deleted successfully",
};
} catch (error) {
console.error("Error deleting master option:", error);
return {
success: false,
error: "Failed to delete master option",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: MasterOptionModel.MasterOption,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Delete a master option (soft delete)",
security: [
{
BearerAuth: [],
},
],
},
},
)
// POST /api/master-options/category/:category/bulk - Bulk create options
.post(
"/category/:category/bulk",
async ({ params, body, currentBranchId, userId }) => {
const { category } = params;
try {
const results = await service.bulkCreateMasterOptions(
{
currentBranchId,
userId,
currentBranchCode: "",
accessibleBranches: [],
userGroups: [],
},
category,
body.options,
);
return {
success: true,
data: results,
count: results.length,
message: `Bulk create completed for category: ${category}`,
};
} catch (error) {
console.error("Error bulk creating master options:", error);
return {
success: false,
error: "Failed to bulk create master options",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
params: t.Object({
category: t.String(),
}),
body: MasterOptionModel.BulkCreateMasterOptions,
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Array(t.Any()),
count: t.Number(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
details: t.Optional(t.String()),
}),
]),
detail: {
description: "Bulk create multiple master options for a category",
security: [
{
BearerAuth: [],
},
],
},
},
);

View File

@@ -0,0 +1,71 @@
import { t } from "elysia";
export const MasterOptionModel = {
// Master option object
MasterOption: t.Object({
id: t.Number(),
branchId: t.String(),
code: t.String(),
name: t.String(),
category: t.String(),
description: t.Optional(t.String()),
value: t.Optional(t.String()),
parentId: t.Optional(t.Number()),
isActive: t.Boolean(),
sortOrder: t.Number(),
level: t.Number(),
createdAt: t.String(),
updatedAt: t.String(),
createdBy: t.Optional(t.String()),
updatedBy: t.Optional(t.String()),
deletedAt: t.Optional(t.String()),
}),
// Create master option
CreateMasterOption: t.Object({
code: t.String({ minLength: 1 }),
name: t.String({ minLength: 1 }),
category: t.String({ minLength: 1 }),
description: t.Optional(t.String()),
value: t.Optional(t.String()),
parentId: t.Optional(t.Number()),
isActive: t.Optional(t.Boolean()),
sortOrder: t.Optional(t.Number()),
level: t.Optional(t.Number()),
}),
// Update master option
UpdateMasterOption: t.Object({
code: t.Optional(t.String({ minLength: 1 })),
name: t.Optional(t.String({ minLength: 1 })),
category: t.Optional(t.String({ minLength: 1 })),
description: t.Optional(t.String()),
value: t.Optional(t.String()),
parentId: t.Optional(t.Number()),
isActive: t.Optional(t.Boolean()),
sortOrder: t.Optional(t.Number()),
level: t.Optional(t.Number()),
}),
// Master option list response
MasterOptionList: t.Object({
success: t.Literal(true),
data: t.Array(t.Any()),
count: t.Number(),
message: t.String(),
}),
// Bulk create master options
BulkCreateMasterOptions: t.Object({
category: t.String({ minLength: 1 }),
options: t.Array(
t.Object({
code: t.String({ minLength: 1 }),
name: t.String({ minLength: 1 }),
value: t.Optional(t.String()),
sortOrder: t.Optional(t.Number()),
level: t.Optional(t.Number()),
}),
),
}),
};

View File

@@ -0,0 +1,355 @@
import { db } from "@/database/db";
import { masterOptions } from "@/database/schema";
import { eq, and, isNull, desc, asc } from "drizzle-orm";
// Allowed categories
export const ALLOWED_CATEGORIES = [
"customer_type",
"customer_status",
"quotation_status",
"payment_term",
"currency",
"tax_rate",
"unit",
"priority",
"industry_type",
"document_type",
] as const;
export type AllowedCategory = (typeof ALLOWED_CATEGORIES)[number];
// Context type
export type Context = {
currentBranchId: string;
userId: string;
currentBranchCode: string;
accessibleBranches: string[];
userGroups: string[];
};
// Get all master options for current branch
export async function getMasterOptionsByBranch(
context: Context,
filters?: {
category?: string;
isActive?: boolean;
},
) {
const { currentBranchId } = context;
const { category, isActive } = filters || {};
const conditions = [eq(masterOptions.branchId, currentBranchId)];
if (category) {
conditions.push(eq(masterOptions.category, category));
}
if (isActive !== undefined) {
conditions.push(eq(masterOptions.isActive, isActive));
}
const options = await db
.select()
.from(masterOptions)
.where(and(...conditions))
.orderBy(asc(masterOptions.sortOrder), asc(masterOptions.id));
return options;
}
// Get master option by ID
export async function getMasterOptionById(context: Context, id: number) {
const { currentBranchId } = context;
const option = await db
.select()
.from(masterOptions)
.where(
and(
eq(masterOptions.id, id),
eq(masterOptions.branchId, currentBranchId),
isNull(masterOptions.deletedAt),
),
)
.limit(1);
return option[0] || null;
}
// Get master options by category
export async function getMasterOptionsByCategory(
context: Context,
category: string,
) {
const { currentBranchId } = context;
const options = await db
.select()
.from(masterOptions)
.where(
and(
eq(masterOptions.branchId, currentBranchId),
eq(masterOptions.category, category),
eq(masterOptions.isActive, true),
isNull(masterOptions.deletedAt),
),
)
.orderBy(asc(masterOptions.sortOrder), asc(masterOptions.id));
return options;
}
// Get all categories
export async function getCategories(context: Context) {
const { currentBranchId } = context;
const categories = await db
.selectDistinct({ category: masterOptions.category })
.from(masterOptions)
.where(
and(
eq(masterOptions.branchId, currentBranchId),
isNull(masterOptions.deletedAt),
),
)
.orderBy(asc(masterOptions.category));
return categories.map((c) => c.category);
}
// Create master option
export async function createMasterOption(
context: Context,
data: {
code: string;
name: string;
category: string;
description?: string;
value?: string;
parentId?: number;
isActive?: boolean;
sortOrder?: number;
level?: number;
},
) {
const { currentBranchId, userId } = context;
// Validate category
if (!ALLOWED_CATEGORIES.includes(data.category as AllowedCategory)) {
throw new Error(
`Invalid category. Allowed categories: ${ALLOWED_CATEGORIES.join(", ")}`,
);
}
// Check if code already exists in this branch and category
const existing = await db
.select()
.from(masterOptions)
.where(
and(
eq(masterOptions.branchId, currentBranchId),
eq(masterOptions.code, data.code),
eq(masterOptions.category, data.category),
isNull(masterOptions.deletedAt),
),
)
.limit(1);
if (existing.length > 0) {
throw new Error(
`Option with code "${data.code}" already exists in category "${data.category}"`,
);
}
// Calculate level if not provided
let level = data.level || 0;
if (data.parentId) {
const parent = await db
.select()
.from(masterOptions)
.where(eq(masterOptions.id, data.parentId))
.limit(1);
if (parent.length > 0) {
level = parent[0].level + 1;
}
}
const [option] = await db
.insert(masterOptions)
.values({
...data,
branchId: currentBranchId,
level,
isActive: data.isActive ?? true,
sortOrder: data.sortOrder ?? 0,
createdBy: userId,
updatedBy: userId,
})
.returning();
return option;
}
// Update master option
export async function updateMasterOption(
context: Context,
id: number,
data: {
code?: string;
name?: string;
category?: string;
description?: string;
value?: string;
parentId?: number;
isActive?: boolean;
sortOrder?: number;
level?: number;
},
) {
const { currentBranchId, userId } = context;
// Check if option exists and belongs to this branch
const existing = await getMasterOptionById(context, id);
if (!existing) {
throw new Error("Master option not found or access denied");
}
// Validate category if provided
if (
data.category &&
!ALLOWED_CATEGORIES.includes(data.category as AllowedCategory)
) {
throw new Error(
`Invalid category. Allowed categories: ${ALLOWED_CATEGORIES.join(", ")}`,
);
}
// Check code uniqueness if changing code
if (data.code && data.code !== existing.code) {
const codeCheck = await db
.select()
.from(masterOptions)
.where(
and(
eq(masterOptions.branchId, currentBranchId),
eq(masterOptions.code, data.code),
eq(masterOptions.category, data.category || existing.category),
isNull(masterOptions.deletedAt),
),
)
.limit(1);
if (codeCheck.length > 0) {
throw new Error(
`Option with code "${data.code}" already exists in this category`,
);
}
}
// Recalculate level if parentId changed
let level = data.level;
if (data.parentId !== undefined && data.parentId !== existing.parentId) {
if (data.parentId) {
const parent = await db
.select()
.from(masterOptions)
.where(eq(masterOptions.id, data.parentId))
.limit(1);
if (parent.length > 0) {
level = parent[0].level + 1;
}
} else {
level = 0;
}
}
const [updated] = await db
.update(masterOptions)
.set({
...data,
level,
updatedBy: userId,
updatedAt: new Date(),
})
.where(eq(masterOptions.id, id))
.returning();
return updated;
}
// Delete master option (soft delete)
export async function deleteMasterOption(context: Context, id: number) {
const { userId } = context;
// Check if option exists
const existing = await getMasterOptionById(context, id);
if (!existing) {
throw new Error("Master option not found or access denied");
}
// Check if this option has children
const children = await db
.select()
.from(masterOptions)
.where(eq(masterOptions.parentId, id))
.limit(1);
if (children.length > 0) {
throw new Error("Cannot delete option that has child options");
}
const [deleted] = await db
.update(masterOptions)
.set({
deletedAt: new Date(),
updatedBy: userId,
})
.where(eq(masterOptions.id, id))
.returning();
return deleted;
}
// Bulk create master options
export async function bulkCreateMasterOptions(
context: Context,
category: string,
options: Array<{
code: string;
name: string;
value?: string;
sortOrder?: number;
level?: number;
}>,
) {
const { currentBranchId, userId } = context;
// Validate category
if (!ALLOWED_CATEGORIES.includes(category as AllowedCategory)) {
throw new Error(
`Invalid category. Allowed categories: ${ALLOWED_CATEGORIES.join(", ")}`,
);
}
const results = [];
for (const opt of options) {
try {
const created = await createMasterOption(context, {
...opt,
category,
});
results.push(created);
} catch (error) {
console.error(`Failed to create option ${opt.code}:`, error);
results.push({
code: opt.code,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
return results;
}

View File

@@ -1,29 +1,25 @@
import { Elysia, t } from "elysia";
import * as service from "./service";
import { QuotationModel } from "./model";
import { branchMiddleware } from "@/middleware/branch";
// Create Elysia instance for quotations module
export const quotations = new Elysia({
prefix: "/quotations",
tags: ["quotations"],
})
.use(branchMiddleware)
.model(QuotationModel)
// GET /api/quotations/:branch - Get all quotations by branch
.get(
"/:branch",
({ params, query }) => {
async ({ params, query, currentBranchId, userId }) => {
const { branch } = params;
const { status } = query as { status?: string };
const quotations = service.getAllQuotations(
branch,
status as
| "draft"
| "sent"
| "accepted"
| "rejected"
| "expired"
| undefined,
const quotations = await service.getQuotationsByBranch(
{ currentBranchId, userId },
status,
);
return {
@@ -79,9 +75,12 @@ export const quotations = new Elysia({
// GET /api/quotations/:branch/:id - Get single quotation by ID
.get(
"/:branch/:id",
({ params }) => {
async ({ params, currentBranchId, userId }) => {
const { branch, id } = params;
const quotation = service.getQuotationByIdAndBranch(branch, id);
const quotation = await service.getQuotationById(
{ currentBranchId, userId },
id,
);
if (!quotation) {
return {
@@ -118,8 +117,11 @@ export const quotations = new Elysia({
// POST /api/quotations - Create new quotation
.post(
"/",
({ body }) => {
const quotation = service.createQuotation(body);
async ({ body, currentBranchId, userId }) => {
const quotation = await service.createQuotation(
{ currentBranchId, userId },
body,
);
return {
success: true,
@@ -142,9 +144,13 @@ export const quotations = new Elysia({
// PUT /api/quotations/:branch/:id - Update quotation
.put(
"/:branch/:id",
({ params, body }) => {
async ({ params, body, currentBranchId, userId }) => {
const { branch, id } = params;
const quotation = service.updateQuotation(branch, id, body);
const quotation = await service.updateQuotation(
{ currentBranchId, userId },
id,
body,
);
if (!quotation) {
return {
@@ -184,9 +190,12 @@ export const quotations = new Elysia({
// DELETE /api/quotations/:branch/:id - Delete quotation
.delete(
"/:branch/:id",
({ params }) => {
async ({ params, currentBranchId, userId }) => {
const { branch, id } = params;
const quotation = service.deleteQuotation(branch, id);
const quotation = await service.deleteQuotation(
{ currentBranchId, userId },
id,
);
if (!quotation) {
return {
@@ -221,4 +230,836 @@ export const quotations = new Elysia({
description: "Delete a quotation",
},
},
)
// =========================================================
// ATTACHMENTS ENDPOINTS
// =========================================================
// GET /api/quotations/:branch/:id/attachments - Get all attachments
.get(
"/:branch/:id/attachments",
async ({ params, currentBranchId, userId }) => {
const { id } = params;
const attachments = await service.getQuotationAttachments(
{ currentBranchId, userId },
id,
);
return {
success: true,
data: attachments,
count: attachments.length,
message: `Found ${attachments.length} attachment(s)`,
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
response: t.Object({
success: t.Boolean(),
data: t.Array(t.Any()),
count: t.Number(),
message: t.String(),
}),
detail: {
description: "Get all attachments for a quotation",
},
},
)
// POST /api/quotations/:branch/:id/attachments/upload - Upload attachment
.post(
"/:branch/:id/attachments/upload",
async ({ params, body, currentBranchId, userId }) => {
const { id } = params;
const { file, description } = body as {
file: File;
description?: string;
};
const attachment = await service.uploadQuotationAttachment(
{ currentBranchId, userId },
id,
file,
description,
userId,
);
return {
success: true,
data: attachment,
message: "Attachment uploaded successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
body: t.Object({
file: t.Any(),
description: t.Optional(t.String()),
}),
response: t.Object({
success: t.Boolean(),
data: t.Any(),
message: t.String(),
}),
detail: {
description: "Upload an attachment to a quotation",
},
},
)
// DELETE /api/quotations/:branch/:id/attachments/:attachmentId - Delete attachment
.delete(
"/:branch/:id/attachments/:attachmentId",
async ({ params, currentBranchId, userId }) => {
const { attachmentId } = params;
const attachment = await service.deleteQuotationAttachment(
{ currentBranchId, userId },
attachmentId,
);
if (!attachment) {
return {
success: false,
error: "Attachment not found",
};
}
return {
success: true,
data: attachment,
message: "Attachment deleted successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
attachmentId: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Any(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Delete an attachment",
},
},
)
// =========================================================
// TOPICS ENDPOINTS
// =========================================================
// GET /api/quotations/:branch/:id/topics - Get all topics
.get(
"/:branch/:id/topics",
async ({ params, currentBranchId, userId }) => {
const { id } = params;
const topics = await service.getQuotationTopics(
{ currentBranchId, userId },
id,
);
return {
success: true,
data: topics,
count: topics.length,
message: `Found ${topics.length} topic(s)`,
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
response: t.Object({
success: t.Boolean(),
data: t.Array(t.Any()),
count: t.Number(),
message: t.String(),
}),
detail: {
description: "Get all topics for a quotation",
},
},
)
// POST /api/quotations/:branch/:id/topics - Create topic
.post(
"/:branch/:id/topics",
async ({ params, body, currentBranchId, userId }) => {
const { id } = params;
const topic = await service.createQuotationTopic(
{ currentBranchId, userId },
id,
body,
);
return {
success: true,
data: topic,
message: "Topic created successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
body: t.Object({
topicType: t.String(),
sortOrder: t.Optional(t.Number()),
}),
response: t.Object({
success: t.Boolean(),
data: t.Any(),
message: t.String(),
}),
detail: {
description: "Create a new topic for a quotation",
},
},
)
// PUT /api/quotations/:branch/:id/topics/:topicId - Update topic
.put(
"/:branch/:id/topics/:topicId",
async ({ params, body, currentBranchId, userId }) => {
const { topicId } = params;
const topic = await service.updateQuotationTopic(
{ currentBranchId, userId },
topicId,
body,
);
if (!topic) {
return {
success: false,
error: "Topic not found",
};
}
return {
success: true,
data: topic,
message: "Topic updated successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
topicId: t.String(),
}),
body: t.Object({
topicType: t.Optional(t.String()),
sortOrder: t.Optional(t.Number()),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Any(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Update a topic",
},
},
)
// DELETE /api/quotations/:branch/:id/topics/:topicId - Delete topic
.delete(
"/:branch/:id/topics/:topicId",
async ({ params, currentBranchId, userId }) => {
const { topicId } = params;
const topic = await service.deleteQuotationTopic(
{ currentBranchId, userId },
topicId,
);
if (!topic) {
return {
success: false,
error: "Topic not found",
};
}
return {
success: true,
data: topic,
message: "Topic deleted successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
topicId: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Any(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Delete a topic",
},
},
)
// GET /api/quotations/:branch/:id/topics/:topicId/items - Get topic items
.get(
"/:branch/:id/topics/:topicId/items",
async ({ params, currentBranchId, userId }) => {
const { topicId } = params;
const items = await service.getQuotationTopicItems(
{ currentBranchId, userId },
topicId,
);
return {
success: true,
data: items,
count: items.length,
message: `Found ${items.length} item(s)`,
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
topicId: t.String(),
}),
response: t.Object({
success: t.Boolean(),
data: t.Array(t.Any()),
count: t.Number(),
message: t.String(),
}),
detail: {
description: "Get all items for a topic",
},
},
)
// POST /api/quotations/:branch/:id/topics/:topicId/items - Create topic item
.post(
"/:branch/:id/topics/:topicId/items",
async ({ params, body, currentBranchId, userId }) => {
const { topicId } = params;
const item = await service.createQuotationTopicItem(
{ currentBranchId, userId },
topicId,
body,
);
return {
success: true,
data: item,
message: "Topic item created successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
topicId: t.String(),
}),
body: t.Object({
content: t.String(),
sortOrder: t.Optional(t.Number()),
}),
response: t.Object({
success: t.Boolean(),
data: t.Any(),
message: t.String(),
}),
detail: {
description: "Create a new item for a topic",
},
},
)
// PUT /api/quotations/:branch/:id/topics/:topicId/items/:itemId - Update topic item
.put(
"/:branch/:id/topics/:topicId/items/:itemId",
async ({ params, body, currentBranchId, userId }) => {
const { itemId } = params;
const item = await service.updateQuotationTopicItem(
{ currentBranchId, userId },
itemId,
body,
);
if (!item) {
return {
success: false,
error: "Topic item not found",
};
}
return {
success: true,
data: item,
message: "Topic item updated successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
topicId: t.String(),
itemId: t.String(),
}),
body: t.Object({
content: t.Optional(t.String()),
sortOrder: t.Optional(t.Number()),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Any(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Update a topic item",
},
},
)
// DELETE /api/quotations/:branch/:id/topics/:topicId/items/:itemId - Delete topic item
.delete(
"/:branch/:id/topics/:topicId/items/:itemId",
async ({ params, currentBranchId, userId }) => {
const { itemId } = params;
const item = await service.deleteQuotationTopicItem(
{ currentBranchId, userId },
itemId,
);
if (!item) {
return {
success: false,
error: "Topic item not found",
};
}
return {
success: true,
data: item,
message: "Topic item deleted successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
topicId: t.String(),
itemId: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Any(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Delete a topic item",
},
},
)
// =========================================================
// FOLLOW-UPS ENDPOINTS
// =========================================================
// GET /api/quotations/:branch/:id/followups - Get all follow-ups
.get(
"/:branch/:id/followups",
async ({ params, currentBranchId, userId }) => {
const { id } = params;
const followups = await service.getQuotationFollowups(
{ currentBranchId, userId },
id,
);
return {
success: true,
data: followups,
count: followups.length,
message: `Found ${followups.length} follow-up(s)`,
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
response: t.Object({
success: t.Boolean(),
data: t.Array(t.Any()),
count: t.Number(),
message: t.String(),
}),
detail: {
description: "Get all follow-ups for a quotation",
},
},
)
// POST /api/quotations/:branch/:id/followups - Create follow-up
.post(
"/:branch/:id/followups",
async ({ params, body, currentBranchId, userId }) => {
const { id } = params;
const followup = await service.createQuotationFollowup(
{ currentBranchId, userId },
id,
body,
);
return {
success: true,
data: followup,
message: "Follow-up created successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
body: t.Object({
followupDate: t.String(),
followupType: t.String(),
contactPerson: t.Optional(t.String()),
contactMethod: t.Optional(t.String()),
outcome: t.Optional(t.String()),
notes: t.Optional(t.String()),
nextFollowupDate: t.Optional(t.String()),
nextAction: t.Optional(t.String()),
}),
response: t.Object({
success: t.Boolean(),
data: t.Any(),
message: t.String(),
}),
detail: {
description: "Create a new follow-up for a quotation",
},
},
)
// PUT /api/quotations/:branch/:id/followups/:followupId - Update follow-up
.put(
"/:branch/:id/followups/:followupId",
async ({ params, body, currentBranchId, userId }) => {
const { followupId } = params;
const followup = await service.updateQuotationFollowup(
{ currentBranchId, userId },
followupId,
body,
);
if (!followup) {
return {
success: false,
error: "Follow-up not found",
};
}
return {
success: true,
data: followup,
message: "Follow-up updated successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
followupId: t.String(),
}),
body: t.Object({
followupDate: t.Optional(t.String()),
followupType: t.Optional(t.String()),
contactPerson: t.Optional(t.String()),
contactMethod: t.Optional(t.String()),
outcome: t.Optional(t.String()),
notes: t.Optional(t.String()),
nextFollowupDate: t.Optional(t.String()),
nextAction: t.Optional(t.String()),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Any(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Update a follow-up",
},
},
)
// DELETE /api/quotations/:branch/:id/followups/:followupId - Delete follow-up
.delete(
"/:branch/:id/followups/:followupId",
async ({ params, currentBranchId, userId }) => {
const { followupId } = params;
const followup = await service.deleteQuotationFollowup(
{ currentBranchId, userId },
followupId,
);
if (!followup) {
return {
success: false,
error: "Follow-up not found",
};
}
return {
success: true,
data: followup,
message: "Follow-up deleted successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
followupId: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Any(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Delete a follow-up",
},
},
)
// =========================================================
// TOPIC DEFAULTS ENDPOINTS
// =========================================================
// GET /api/quotations/topic-defaults/:productType - Get topic defaults
.get(
"/topic-defaults/:productType",
async ({ params }) => {
const { productType } = params;
const defaults = await service.getQuotationTopicDefaults(productType);
return {
success: true,
data: defaults,
count: defaults.length,
message: `Found ${defaults.length} topic default(s) for product type: ${productType}`,
};
},
{
params: t.Object({
productType: t.String(),
}),
response: t.Object({
success: t.Boolean(),
data: t.Array(t.Any()),
count: t.Number(),
message: t.String(),
}),
detail: {
description: "Get all topic defaults for a product type",
},
},
)
// GET /api/quotations/topic-defaults/id/:id - Get single topic default
.get(
"/topic-defaults/id/:id",
async ({ params }) => {
const { id } = params;
const defaultItem = await service.getQuotationTopicDefaultById(
Number.parseInt(id),
);
if (!defaultItem) {
return {
success: false,
error: "Topic default not found",
};
}
return {
success: true,
data: defaultItem,
};
},
{
params: t.Object({
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Any(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Get a single topic default by ID",
},
},
)
// POST /api/quotations/topic-defaults - Create topic default
.post(
"/topic-defaults",
async ({ body }) => {
const defaultItem = await service.createQuotationTopicDefault(body);
return {
success: true,
data: defaultItem,
message: "Topic default created successfully",
};
},
{
body: t.Object({
productType: t.String(),
topicType: t.String(),
content: t.String(),
sortOrder: t.Optional(t.Number()),
isActive: t.Optional(t.Boolean()),
}),
response: t.Object({
success: t.Boolean(),
data: t.Any(),
message: t.String(),
}),
detail: {
description: "Create a new topic default",
},
},
)
// PUT /api/quotations/topic-defaults/:id - Update topic default
.put(
"/topic-defaults/:id",
async ({ params, body }) => {
const { id } = params;
const defaultItem = await service.updateQuotationTopicDefault(
Number.parseInt(id),
body,
);
if (!defaultItem) {
return {
success: false,
error: "Topic default not found",
};
}
return {
success: true,
data: defaultItem,
message: "Topic default updated successfully",
};
},
{
params: t.Object({
id: t.String(),
}),
body: t.Object({
productType: t.Optional(t.String()),
topicType: t.Optional(t.String()),
content: t.Optional(t.String()),
sortOrder: t.Optional(t.Number()),
isActive: t.Optional(t.Boolean()),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Any(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Update a topic default",
},
},
)
// DELETE /api/quotations/topic-defaults/:id - Delete topic default
.delete(
"/topic-defaults/:id",
async ({ params }) => {
const { id } = params;
const defaultItem = await service.deleteQuotationTopicDefault(
Number.parseInt(id),
);
if (!defaultItem) {
return {
success: false,
error: "Topic default not found",
};
}
return {
success: true,
data: defaultItem,
message: "Topic default deleted successfully",
};
},
{
params: t.Object({
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: t.Any(),
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Delete a topic default",
},
},
);

View File

@@ -4,63 +4,94 @@ import { t } from "elysia";
export const QuotationModel = {
Quotation: t.Object({
id: t.String(),
quotationNumber: t.String(),
branch: t.String(),
code: t.String(),
branchId: t.String(),
customerId: t.String(),
customerName: t.String(),
date: t.String({ format: "date-time" }),
quotationDate: t.String({ format: "date-time" }),
validUntil: t.String({ format: "date-time" }),
subtotal: t.Number(),
currencyCode: t.String(),
exchangeRate: t.Number(),
baseCurrencyAmount: t.Nullable(t.String()),
subtotal: t.String(),
discount: t.String(),
taxRate: t.Number(),
taxAmount: t.Number(),
totalAmount: t.Number(),
taxAmount: t.String(),
totalAmount: t.String(),
status: t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
t.Literal("new_job_draft"),
t.Literal("new_job_sent"),
t.Literal("follow_up"),
t.Literal("closed_lost"),
t.Literal("awarded"),
t.Literal("cancelled"),
]),
revisionNo: t.Nullable(t.Number()),
parentQuotationId: t.Nullable(t.String()),
notes: t.Optional(t.String()),
createdBy: t.String(),
updatedBy: 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" }),
quotationDate: t.String({ format: "date-time" }),
validUntil: t.String({ format: "date-time" }),
subtotal: t.Number(),
currencyCode: t.Union([
t.Literal("THB"),
t.Literal("USD"),
t.Literal("EUR"),
t.Literal("JPY"),
t.Literal("CNY"),
]),
exchangeRate: t.Number(),
subtotal: t.String(),
discount: t.String(),
taxRate: t.Number(),
taxAmount: t.String(),
totalAmount: t.String(),
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"),
t.Literal("new_job_draft"),
t.Literal("new_job_sent"),
t.Literal("follow_up"),
t.Literal("closed_lost"),
t.Literal("awarded"),
t.Literal("cancelled"),
]),
),
}),
UpdateQuotation: t.Object({
customerId: t.Optional(t.String()),
customerName: t.Optional(t.String()),
date: t.Optional(t.String({ format: "date-time" })),
quotationDate: t.Optional(t.String({ format: "date-time" })),
validUntil: t.Optional(t.String({ format: "date-time" })),
subtotal: t.Optional(t.Number()),
currencyCode: t.Optional(
t.Union([
t.Literal("THB"),
t.Literal("USD"),
t.Literal("EUR"),
t.Literal("JPY"),
t.Literal("CNY"),
]),
),
exchangeRate: t.Optional(t.Number()),
subtotal: t.Optional(t.String()),
discount: t.Optional(t.String()),
taxRate: t.Optional(t.Number()),
taxAmount: t.Optional(t.String()),
totalAmount: t.Optional(t.String()),
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"),
t.Literal("new_job_draft"),
t.Literal("new_job_sent"),
t.Literal("follow_up"),
t.Literal("closed_lost"),
t.Literal("awarded"),
t.Literal("cancelled"),
]),
),
}),
@@ -70,23 +101,29 @@ export const QuotationModel = {
data: t.Array(
t.Object({
id: t.String(),
quotationNumber: t.String(),
branch: t.String(),
code: t.String(),
branchId: t.String(),
customerId: t.String(),
customerName: t.String(),
date: t.String(),
validUntil: t.String(),
subtotal: t.Number(),
quotationDate: t.String({ format: "date-time" }),
validUntil: t.String({ format: "date-time" }),
currencyCode: t.String(),
exchangeRate: t.Number(),
baseCurrencyAmount: t.Nullable(t.String()),
subtotal: t.String(),
discount: t.String(),
taxRate: t.Number(),
taxAmount: t.Number(),
totalAmount: t.Number(),
taxAmount: t.String(),
totalAmount: t.String(),
status: t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
t.Literal("new_job_draft"),
t.Literal("new_job_sent"),
t.Literal("follow_up"),
t.Literal("closed_lost"),
t.Literal("awarded"),
t.Literal("cancelled"),
]),
revisionNo: t.Nullable(t.Number()),
parentQuotationId: t.Nullable(t.String()),
notes: t.Optional(t.String()),
createdAt: t.String(),
updatedAt: t.String(),
@@ -97,8 +134,133 @@ export const QuotationModel = {
}),
};
// Quotation Item Models
export const QuotationItemModel = {
QuotationItem: t.Object({
id: t.String(),
quotationId: t.String(),
itemNumber: t.String(),
productType: t.String(),
description: t.String(),
quantity: t.String(),
unit: t.String(),
unitPrice: t.String(),
discount: t.String(),
discountType: t.Union([t.Literal("amount"), t.Literal("percentage")]),
taxRate: t.Number(),
totalPrice: t.String(),
notes: t.Nullable(t.String()),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
}),
CreateQuotationItem: t.Object({
itemNumber: t.String(),
productType: t.String(),
description: t.String(),
quantity: t.String(),
unit: t.String(),
unitPrice: t.String(),
discount: t.String(),
discountType: t.Union([t.Literal("amount"), t.Literal("percentage")]),
taxRate: t.Number(),
totalPrice: t.String(),
notes: t.Optional(t.String()),
}),
UpdateQuotationItem: t.Object({
itemNumber: t.Optional(t.String()),
productType: t.Optional(t.String()),
description: t.Optional(t.String()),
quantity: t.Optional(t.String()),
unit: t.Optional(t.String()),
unitPrice: t.Optional(t.String()),
discount: t.Optional(t.String()),
discountType: t.Optional(
t.Union([t.Literal("amount"), t.Literal("percentage")]),
),
taxRate: t.Optional(t.Number()),
totalPrice: t.Optional(t.String()),
notes: t.Optional(t.String()),
}),
QuotationItemList: t.Object({
success: t.Boolean(),
data: t.Array(
t.Object({
id: t.String(),
quotationId: t.String(),
itemNumber: t.String(),
productType: t.String(),
description: t.String(),
quantity: t.String(),
unit: t.String(),
unitPrice: t.String(),
discount: t.String(),
discountType: t.Union([t.Literal("amount"), t.Literal("percentage")]),
taxRate: t.Number(),
totalPrice: t.String(),
notes: t.Nullable(t.String()),
createdAt: t.String(),
updatedAt: t.String(),
}),
),
count: t.Number(),
message: t.Optional(t.String()),
}),
};
// Quotation Customer Models
export const QuotationCustomerModel = {
QuotationCustomer: t.Object({
id: t.String(),
quotationId: t.String(),
customerId: t.String(),
role: t.String(),
isPrimary: t.Nullable(t.Boolean()),
createdAt: t.String({ format: "date-time" }),
}),
CreateQuotationCustomer: t.Object({
customerId: t.String(),
role: t.String(),
isPrimary: t.Optional(t.Boolean()),
}),
QuotationCustomerList: t.Object({
success: t.Boolean(),
data: t.Array(
t.Object({
id: t.String(),
quotationId: t.String(),
customerId: t.String(),
role: t.String(),
isPrimary: t.Nullable(t.Boolean()),
createdAt: 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;
export type QuotationItem = typeof QuotationItemModel.QuotationItem.static;
export type CreateQuotationItem =
typeof QuotationItemModel.CreateQuotationItem.static;
export type UpdateQuotationItem =
typeof QuotationItemModel.UpdateQuotationItem.static;
export type QuotationItemList =
typeof QuotationItemModel.QuotationItemList.static;
export type QuotationCustomer =
typeof QuotationCustomerModel.QuotationCustomer.static;
export type CreateQuotationCustomer =
typeof QuotationCustomerModel.CreateQuotationCustomer.static;
export type QuotationCustomerList =
typeof QuotationCustomerModel.QuotationCustomerList.static;

File diff suppressed because it is too large Load Diff