setup
This commit is contained in:
380
src/modules/audit-logs/controller.ts
Normal file
380
src/modules/audit-logs/controller.ts
Normal 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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
1
src/modules/audit-logs/index.ts
Normal file
1
src/modules/audit-logs/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { auditLogs } from "./controller";
|
||||
30
src/modules/audit-logs/model.ts
Normal file
30
src/modules/audit-logs/model.ts
Normal 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(),
|
||||
}),
|
||||
};
|
||||
256
src/modules/audit-logs/service.ts
Normal file
256
src/modules/audit-logs/service.ts
Normal 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
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
434
src/modules/industrial-estates/controller.ts
Normal file
434
src/modules/industrial-estates/controller.ts
Normal 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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
50
src/modules/industrial-estates/model.ts
Normal file
50
src/modules/industrial-estates/model.ts
Normal 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(),
|
||||
}),
|
||||
};
|
||||
294
src/modules/industrial-estates/service.ts
Normal file
294
src/modules/industrial-estates/service.ts
Normal 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;
|
||||
}
|
||||
511
src/modules/locations/controller.ts
Normal file
511
src/modules/locations/controller.ts
Normal 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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
54
src/modules/locations/model.ts
Normal file
54
src/modules/locations/model.ts
Normal 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(),
|
||||
}),
|
||||
};
|
||||
355
src/modules/locations/service.ts
Normal file
355
src/modules/locations/service.ts
Normal 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;
|
||||
}
|
||||
503
src/modules/master-options/controller.ts
Normal file
503
src/modules/master-options/controller.ts
Normal 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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
71
src/modules/master-options/model.ts
Normal file
71
src/modules/master-options/model.ts
Normal 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()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
};
|
||||
355
src/modules/master-options/service.ts
Normal file
355
src/modules/master-options/service.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user