This commit is contained in:
2026-07-16 09:53:14 +07:00
parent 0fc112a2e8
commit 29cec708a3
1236 changed files with 212848 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { announcements } from '@/db/schema';
import { getAnnouncementByIdForAccess } from '@/features/announcements/server/announcement-data';
import { readStoredAnnouncementAttachment } from '@/lib/announcement-storage';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
export const runtime = 'nodejs';
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { organization, session } = await requireOrganizationAccess();
const { id } = await params;
const announcementId = Number(id);
if (Number.isNaN(announcementId)) {
return NextResponse.json({ message: 'Invalid announcement request' }, { status: 400 });
}
const announcement = await getAnnouncementByIdForAccess({
id: announcementId,
organizationId: organization.id,
permissions: session.user.effectivePermissions
});
if (!announcement || !announcement.attachmentStorageKey) {
return NextResponse.json({ message: 'ไม่พบไฟล์แนบประกาศ' }, { status: 404 });
}
const persisted = await db.query.announcements.findFirst({
where: and(
eq(announcements.id, announcementId),
eq(announcements.organizationId, organization.id)
)
});
if (!persisted?.attachmentStorageKey || !persisted.attachmentFileType || !persisted.attachmentFileName) {
return NextResponse.json({ message: 'ไม่พบไฟล์แนบประกาศ' }, { status: 404 });
}
const file = await readStoredAnnouncementAttachment(persisted.attachmentStorageKey);
const disposition =
persisted.attachmentFileType === 'application/pdf' ||
persisted.attachmentFileType.startsWith('image/')
? 'inline'
: 'attachment';
return new NextResponse(file, {
headers: {
'Content-Type': persisted.attachmentFileType,
'Content-Length': String(persisted.attachmentFileSize ?? file.byteLength),
'Content-Disposition': `${disposition}; filename*=UTF-8''${encodeURIComponent(persisted.attachmentFileName)}`,
'Cache-Control': 'private, no-store'
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return NextResponse.json({ message: 'ไม่พบไฟล์แนบประกาศ' }, { status: 404 });
}
return NextResponse.json({ message: 'ไม่สามารถเปิดไฟล์แนบประกาศได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,122 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { announcements } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
getAnnouncementByIdForAccess,
isAnnouncementEligibleForPinning,
serializeAnnouncementInternal
} from '@/features/announcements/server/announcement-data';
import { hasPermission } from '@/lib/auth/authorization';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
function parseId(value: string) {
const id = Number(value);
if (Number.isNaN(id)) {
throw new Error('รหัสประกาศไม่ถูกต้อง');
}
return id;
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { organization, session } = await requireOrganizationAccess();
if (!hasPermission(session.user.effectivePermissions, 'announcement:publish')) {
return NextResponse.json(
{ message: 'คุณไม่มีสิทธิ์จัดการการปักหมุดประกาศ' },
{ status: 403 }
);
}
const { id } = await params;
const announcementId = parseId(id);
const requestMetadata = getRequestMetadata(request);
const existing = await getAnnouncementByIdForAccess({
id: announcementId,
organizationId: organization.id,
permissions: session.user.effectivePermissions
});
if (!existing) {
return NextResponse.json({ message: 'ไม่พบประกาศ' }, { status: 404 });
}
if (
!isAnnouncementEligibleForPinning({
status: existing.status,
startDate: existing.startDate,
endDate: existing.endDate
})
) {
return NextResponse.json(
{ message: 'สามารถปักหมุดได้เฉพาะประกาศที่เผยแพร่แล้วเท่านั้น' },
{ status: 400 }
);
}
if (existing.isPin) {
return NextResponse.json({
success: true,
message: 'ปักหมุดประกาศเรียบร้อยแล้ว'
});
}
await db
.update(announcements)
.set({
isPin: true,
updatedBy: session.user.id,
updatedAt: new Date()
})
.where(and(eq(announcements.id, announcementId), eq(announcements.organizationId, organization.id)));
const updated = await getAnnouncementByIdForAccess({
id: announcementId,
organizationId: organization.id,
permissions: session.user.effectivePermissions
});
if (!updated) {
throw new Error('ไม่พบข้อมูลประกาศหลังปักหมุด');
}
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: AUDIT_ACTION.ANNOUNCEMENT_PINNED,
entityName: updated.title,
entityId: updated.id,
oldValue: toAuditValue(serializeAnnouncementInternal(existing)),
newValue: toAuditValue(serializeAnnouncementInternal(updated)),
...requestMetadata
});
return NextResponse.json({
success: true,
message: 'ปักหมุดประกาศเรียบร้อยแล้ว'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'ไม่สามารถปักหมุดประกาศได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,765 @@
import { and, eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { announcements } from "@/db/schema";
import {
canDeleteContent,
canEditContent,
canTransitionContent,
getNextContentStatus,
} from "@/features/content-approval/lib/workflow";
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue,
} from "@/features/audit-logs/server/audit-service";
import {
getAnnouncementByIdForAccess,
getAnnouncementReviewRecipientIds,
getOrganizationAnnouncementRecipientIds,
serializeAnnouncementInternal,
serializeAnnouncementForAccess,
} from "@/features/announcements/server/announcement-data";
import {
announcementDraftActionSchema,
announcementDraftSchema,
} from "@/features/announcements/schemas/announcement";
import {
createAnnouncementNotification,
createContentWorkflowNotification,
} from "@/features/notifications/server/notification-service";
import { AuthError, requireOrganizationAccess } from "@/lib/auth/session";
import {
ANNOUNCEMENT_MAX_FILE_SIZE,
deleteStoredAnnouncementAttachment,
saveAnnouncementAttachment,
} from "@/lib/announcement-storage";
import { getDatabaseErrorMessage } from "@/lib/db-errors";
import { db } from "@/lib/db";
const SUPPORTED_ATTACHMENT_TYPES = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
]);
function normalizeDateValue(value: FormDataEntryValue | null) {
if (typeof value !== "string") return "";
return value.trim();
}
function parseBooleanValue(value: FormDataEntryValue | null) {
return value === "true";
}
function validateAttachment(file: File | null) {
if (!file || file.size === 0) return null;
if (!SUPPORTED_ATTACHMENT_TYPES.has(file.type)) {
throw new Error(
"รองรับไฟล์แนบ PDF, JPG, JPEG, PNG, DOC, DOCX, XLS และ XLSX เท่านั้น",
);
}
if (file.size > ANNOUNCEMENT_MAX_FILE_SIZE) {
throw new Error("ไฟล์แนบต้องมีขนาดไม่เกิน 10MB");
}
return file;
}
function parseId(value: string) {
const id = Number(value);
if (Number.isNaN(id)) {
throw new Error("รหัสประกาศไม่ถูกต้อง");
}
return id;
}
function buildWorkflowMessage(comment: string | null, fallback: string) {
return comment ? `${fallback} หมายเหตุ: ${comment}` : fallback;
}
async function parseAnnouncementFormData(request: NextRequest) {
const formData = await request.formData();
const rawValues = {
title: String(formData.get("title") ?? ""),
content: String(formData.get("content") ?? ""),
startDate: normalizeDateValue(formData.get("startDate")),
endDate: normalizeDateValue(formData.get("endDate")) || undefined,
removeAttachment: parseBooleanValue(formData.get("removeAttachment")),
};
const parsedValues = announcementDraftSchema.safeParse(rawValues);
if (!parsedValues.success) {
throw new Error(
parsedValues.error.issues[0]?.message ?? "ข้อมูลประกาศไม่ถูกต้อง",
);
}
const parsedAction = announcementDraftActionSchema.safeParse(
String(formData.get("action") ?? "draft"),
);
if (!parsedAction.success) {
throw new Error("คำสั่งการบันทึกประกาศไม่ถูกต้อง");
}
return {
action: parsedAction.data,
values: parsedValues.data,
attachment: validateAttachment(formData.get("attachment") as File | null),
};
}
export async function GET(
_request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
try {
const { organization, session } = await requireOrganizationAccess();
const { id: rawId } = await context.params;
const id = parseId(rawId);
const row = await getAnnouncementByIdForAccess({
id,
organizationId: organization.id,
permissions: session.user.effectivePermissions,
});
if (!row) {
return NextResponse.json({ message: "ไม่พบประกาศ" }, { status: 404 });
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: "โหลดรายละเอียดประกาศเรียบร้อยแล้ว",
announcement: serializeAnnouncementForAccess({
row,
permissions: session.user.effectivePermissions,
currentUserId: session.user.id,
}),
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json(
{ message: error.message },
{ status: error.status },
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json(
{ message: "ไม่สามารถโหลดรายละเอียดประกาศได้" },
{ status: 500 },
);
}
}
export async function PATCH(
request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
try {
const { organization, session } = await requireOrganizationAccess();
const requestMetadata = getRequestMetadata(request);
const { id: rawId } = await context.params;
const id = parseId(rawId);
const [existing] = await db
.select()
.from(announcements)
.where(
and(
eq(announcements.id, id),
eq(announcements.organizationId, organization.id),
),
)
.limit(1);
if (!existing) {
return NextResponse.json({ message: "ไม่พบประกาศ" }, { status: 404 });
}
const permissions = session.user.effectivePermissions;
const isOwner = existing.createdBy === session.user.id;
const previousSerialized = serializeAnnouncementInternal({
...existing,
createdByName: null,
updatedByName: null,
});
const contentType = request.headers.get("content-type") ?? "";
let nextStatus = existing.status;
let reviewComment = existing.reviewComment;
let multipartAction: "draft" | "submit" | null = null;
let successMessage = "อัปเดตประกาศเรียบร้อยแล้ว";
if (contentType.includes("multipart/form-data")) {
if (
!canEditContent({
module: "announcement",
permissions,
status: existing.status,
isOwner,
})
) {
return NextResponse.json(
{ message: "ไม่มีสิทธิ์แก้ไขประกาศในสถานะปัจจุบัน" },
{ status: 403 },
);
}
const { action, values, attachment } =
await parseAnnouncementFormData(request);
multipartAction = action;
const canSubmitAgain = canTransitionContent({
module: "announcement",
action: "submit",
currentStatus: existing.status,
permissions,
isOwner,
});
if (action === "submit") {
if (!canSubmitAgain) {
return NextResponse.json(
{ message: "ไม่มีสิทธิ์ส่งประกาศเพื่อตรวจสอบ" },
{ status: 403 },
);
}
nextStatus = getNextContentStatus("submit");
successMessage = "ส่งประกาศเพื่อตรวจสอบเรียบร้อยแล้ว";
} else {
nextStatus = existing.status;
successMessage = canSubmitAgain
? "บันทึกร่างประกาศเรียบร้อยแล้ว"
: "บันทึกการแก้ไขประกาศเรียบร้อยแล้ว";
}
await db
.update(announcements)
.set({
title: values.title.trim(),
content: values.content.trim(),
startDate: new Date(values.startDate),
endDate: values.endDate?.trim() ? new Date(values.endDate) : null,
status: nextStatus,
submittedAt: action === "submit" ? new Date() : existing.submittedAt,
submittedBy:
action === "submit" ? session.user.id : existing.submittedBy,
updatedBy: session.user.id,
updatedAt: new Date(),
...(values.removeAttachment && !attachment
? {
attachmentFileName: null,
attachmentFileType: null,
attachmentFileSize: null,
attachmentStorageKey: null,
attachmentFileUrl: null,
}
: {}),
})
.where(
and(
eq(announcements.id, id),
eq(announcements.organizationId, organization.id),
),
);
if (
values.removeAttachment &&
!attachment &&
existing.attachmentStorageKey
) {
await deleteStoredAnnouncementAttachment(existing.attachmentStorageKey);
}
if (attachment) {
const storedAttachment = await saveAnnouncementAttachment({
file: attachment,
organizationId: organization.id,
announcementId: id,
});
await db
.update(announcements)
.set({
attachmentFileName: storedAttachment.fileName,
attachmentFileType: storedAttachment.fileType,
attachmentFileSize: storedAttachment.fileSize,
attachmentStorageKey: storedAttachment.storageKey,
attachmentFileUrl: storedAttachment.fileUrl,
updatedBy: session.user.id,
updatedAt: new Date(),
})
.where(
and(
eq(announcements.id, id),
eq(announcements.organizationId, organization.id),
),
);
if (existing.attachmentStorageKey) {
await deleteStoredAnnouncementAttachment(
existing.attachmentStorageKey,
);
}
}
} else {
const body = (await request.json()) as {
action?:
| "submit"
| "approve"
| "return"
| "reject"
| "publish"
| "archive";
status?:
| "draft"
| "pending_review"
| "approved"
| "published"
| "returned"
| "rejected"
| "archived";
comment?: string;
};
const normalizedAction =
body.action ??
(body.status === "pending_review"
? "submit"
: body.status === "published"
? "publish"
: body.status === "archived"
? "archive"
: undefined);
if (normalizedAction === undefined) {
return NextResponse.json(
{ message: "ไม่มีข้อมูลสำหรับอัปเดต" },
{ status: 400 },
);
}
if (
normalizedAction &&
!canTransitionContent({
module: "announcement",
action: normalizedAction,
currentStatus: existing.status,
permissions,
isOwner,
})
) {
return NextResponse.json(
{ message: "ไม่สามารถเปลี่ยนสถานะประกาศได้" },
{ status: 403 },
);
}
nextStatus = normalizedAction
? getNextContentStatus(normalizedAction)
: existing.status;
reviewComment = body.comment?.trim() || existing.reviewComment;
successMessage =
normalizedAction === "publish"
? "เผยแพร่ประกาศเรียบร้อยแล้ว"
: normalizedAction === "archive"
? "ยกเลิกเผยแพร่ออกจากหน้าบ้านเรียบร้อยแล้ว"
: normalizedAction === "approve"
? "อนุมัติประกาศเรียบร้อยแล้ว"
: normalizedAction === "return"
? "ส่งประกาศกลับเพื่อแก้ไขเรียบร้อยแล้ว"
: normalizedAction === "reject"
? "ปฏิเสธประกาศเรียบร้อยแล้ว"
: "อัปเดตประกาศเรียบร้อยแล้ว";
await db
.update(announcements)
.set({
status: nextStatus,
isPin: normalizedAction === "archive" ? false : existing.isPin,
submittedAt:
normalizedAction === "submit" ? new Date() : existing.submittedAt,
submittedBy:
normalizedAction === "submit"
? session.user.id
: existing.submittedBy,
reviewedAt:
normalizedAction === "approve" ||
normalizedAction === "return" ||
normalizedAction === "reject"
? new Date()
: existing.reviewedAt,
reviewedBy:
normalizedAction === "approve" ||
normalizedAction === "return" ||
normalizedAction === "reject"
? session.user.id
: existing.reviewedBy,
approvedAt:
normalizedAction === "approve" ? new Date() : existing.approvedAt,
approvedBy:
normalizedAction === "approve"
? session.user.id
: existing.approvedBy,
publishedAt:
normalizedAction === "publish"
? (existing.publishedAt ?? new Date())
: existing.publishedAt,
publishedBy:
normalizedAction === "publish"
? session.user.id
: existing.publishedBy,
reviewComment,
updatedBy: session.user.id,
updatedAt: new Date(),
})
.where(
and(
eq(announcements.id, id),
eq(announcements.organizationId, organization.id),
),
);
}
const detailRow = await getAnnouncementByIdForAccess({
id,
organizationId: organization.id,
permissions: session.user.effectivePermissions,
});
if (!detailRow) {
throw new Error("ไม่พบข้อมูลประกาศหลังอัปเดต");
}
const serializedAnnouncement = serializeAnnouncementInternal(detailRow);
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: AUDIT_ACTION.ANNOUNCEMENT_UPDATE,
entityName: detailRow.title,
entityId: detailRow.id,
oldValue: toAuditValue(previousSerialized),
newValue: toAuditValue(serializedAnnouncement),
...requestMetadata,
});
if (multipartAction === "draft") {
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: AUDIT_ACTION.ANNOUNCEMENT_DRAFT_SAVED,
entityName: detailRow.title,
entityId: detailRow.id,
oldValue: toAuditValue(previousSerialized),
newValue: toAuditValue(serializedAnnouncement),
...requestMetadata,
});
}
if (
existing.status !== "pending_review" &&
nextStatus === "pending_review"
) {
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: AUDIT_ACTION.ANNOUNCEMENT_SUBMIT,
entityName: detailRow.title,
entityId: detailRow.id,
oldValue: toAuditValue(previousSerialized),
newValue: toAuditValue(serializedAnnouncement),
...requestMetadata,
});
const reviewerIds = await getAnnouncementReviewRecipientIds({
organizationId: organization.id,
excludeUserIds: [session.user.id],
});
await Promise.all(
reviewerIds.map((userId) =>
createContentWorkflowNotification({
organizationId: organization.id,
userId,
title: `มีประกาศใหม่รอตรวจสอบ: ${detailRow.title}`,
message:
"มีการส่งประกาศเข้าสู่คิวตรวจสอบ กรุณาเปิดเพื่อตรวจสอบและอนุมัติ",
type: "CONTENT_SUBMITTED",
referenceId: detailRow.id,
referenceType: "content_submission",
}),
),
);
}
if (existing.status !== "approved" && nextStatus === "approved") {
await createContentWorkflowNotification({
organizationId: organization.id,
userId: detailRow.createdBy,
title: `ประกาศได้รับการอนุมัติ: ${detailRow.title}`,
message: buildWorkflowMessage(
reviewComment,
"ประกาศของคุณผ่านการอนุมัติแล้ว",
),
type: "CONTENT_APPROVED",
referenceId: detailRow.id,
referenceType: "content_approval",
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: AUDIT_ACTION.ANNOUNCEMENT_APPROVE,
entityName: detailRow.title,
entityId: detailRow.id,
oldValue: toAuditValue(previousSerialized),
newValue: toAuditValue(serializedAnnouncement),
...requestMetadata,
});
}
if (existing.status !== "returned" && nextStatus === "returned") {
await createContentWorkflowNotification({
organizationId: organization.id,
userId: detailRow.createdBy,
title: `ประกาศถูกส่งกลับแก้ไข: ${detailRow.title}`,
message: buildWorkflowMessage(
reviewComment,
"กรุณาปรับแก้และส่งตรวจสอบอีกครั้ง",
),
type: "CONTENT_RETURNED",
referenceId: detailRow.id,
referenceType: "content_approval",
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: AUDIT_ACTION.ANNOUNCEMENT_RETURN,
entityName: detailRow.title,
entityId: detailRow.id,
oldValue: toAuditValue(previousSerialized),
newValue: toAuditValue(serializedAnnouncement),
...requestMetadata,
});
}
if (existing.status !== "rejected" && nextStatus === "rejected") {
await createContentWorkflowNotification({
organizationId: organization.id,
userId: detailRow.createdBy,
title: `ประกาศไม่ผ่านการอนุมัติ: ${detailRow.title}`,
message: buildWorkflowMessage(reviewComment, "กรุณาปรับแก้ก่อนส่งใหม่"),
type: "CONTENT_REJECTED",
referenceId: detailRow.id,
referenceType: "content_approval",
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: AUDIT_ACTION.ANNOUNCEMENT_REJECT,
entityName: detailRow.title,
entityId: detailRow.id,
oldValue: toAuditValue(previousSerialized),
newValue: toAuditValue(serializedAnnouncement),
...requestMetadata,
});
}
if (existing.status !== "published" && nextStatus === "published") {
const userIds = await getOrganizationAnnouncementRecipientIds(
organization.id,
);
await createAnnouncementNotification({
organizationId: organization.id,
userIds,
title: `ประกาศใหม่: ${detailRow.title}`,
message: "มีประกาศใหม่เผยแพร่แล้ว กรุณาเปิดอ่านรายละเอียด",
referenceId: detailRow.id,
});
await createContentWorkflowNotification({
organizationId: organization.id,
userId: detailRow.createdBy,
title: `ประกาศถูกเผยแพร่แล้ว: ${detailRow.title}`,
message: "ประกาศของคุณถูกเผยแพร่เรียบร้อยแล้ว",
type: "CONTENT_PUBLISHED",
referenceId: detailRow.id,
referenceType: "content_published",
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: AUDIT_ACTION.ANNOUNCEMENT_PUBLISH,
entityName: detailRow.title,
entityId: detailRow.id,
oldValue: toAuditValue(previousSerialized),
newValue: toAuditValue(serializedAnnouncement),
...requestMetadata,
});
}
if (existing.status !== "archived" && nextStatus === "archived") {
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: AUDIT_ACTION.ANNOUNCEMENT_ARCHIVE,
entityName: detailRow.title,
entityId: detailRow.id,
oldValue: toAuditValue(previousSerialized),
newValue: toAuditValue(serializedAnnouncement),
...requestMetadata,
});
}
const responseAnnouncement = serializeAnnouncementForAccess({
row: detailRow,
permissions: session.user.effectivePermissions,
currentUserId: session.user.id,
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: successMessage,
announcement: responseAnnouncement,
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json(
{ message: error.message },
{ status: error.status },
);
}
if (error instanceof Error && !error.message.includes("Database")) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
console.error("Failed to update announcement", error);
return NextResponse.json(
{ message: getDatabaseErrorMessage(error) },
{ status: 500 },
);
}
}
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
try {
const { organization, session } = await requireOrganizationAccess();
const requestMetadata = getRequestMetadata(request);
const { id: rawId } = await context.params;
const id = parseId(rawId);
const [existing] = await db
.select()
.from(announcements)
.where(
and(
eq(announcements.id, id),
eq(announcements.organizationId, organization.id),
),
)
.limit(1);
if (!existing) {
return NextResponse.json({ message: "ไม่พบประกาศ" }, { status: 404 });
}
if (
!canDeleteContent({
module: "announcement",
permissions: session.user.effectivePermissions,
status: existing.status,
isOwner: existing.createdBy === session.user.id,
})
) {
return NextResponse.json(
{ message: "ไม่มีสิทธิ์ยกเลิกเผยแพร่นี้" },
{ status: 403 },
);
}
await db
.delete(announcements)
.where(
and(
eq(announcements.id, id),
eq(announcements.organizationId, organization.id),
),
);
if (existing.attachmentStorageKey) {
await deleteStoredAnnouncementAttachment(existing.attachmentStorageKey);
}
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: AUDIT_ACTION.ANNOUNCEMENT_DELETE,
entityName: existing.title,
entityId: existing.id,
oldValue: toAuditValue(
serializeAnnouncementInternal({
...existing,
createdByName: null,
updatedByName: null,
}),
),
newValue: null,
...requestMetadata,
});
return NextResponse.json({
success: true,
message: "ยกเลิกเผยแพร่เรียบร้อยแล้ว",
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json(
{ message: error.message },
{ status: error.status },
);
}
if (error instanceof Error && !error.message.includes("Database")) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
console.error("Failed to delete announcement", error);
return NextResponse.json(
{ message: getDatabaseErrorMessage(error) },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,108 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { announcements } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
getAnnouncementByIdForAccess,
serializeAnnouncementInternal
} from '@/features/announcements/server/announcement-data';
import { hasPermission } from '@/lib/auth/authorization';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
function parseId(value: string) {
const id = Number(value);
if (Number.isNaN(id)) {
throw new Error('รหัสประกาศไม่ถูกต้อง');
}
return id;
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { organization, session } = await requireOrganizationAccess();
if (!hasPermission(session.user.effectivePermissions, 'announcement:publish')) {
return NextResponse.json(
{ message: 'คุณไม่มีสิทธิ์จัดการการปักหมุดประกาศ' },
{ status: 403 }
);
}
const { id } = await params;
const announcementId = parseId(id);
const requestMetadata = getRequestMetadata(request);
const existing = await getAnnouncementByIdForAccess({
id: announcementId,
organizationId: organization.id,
permissions: session.user.effectivePermissions
});
if (!existing) {
return NextResponse.json({ message: 'ไม่พบประกาศ' }, { status: 404 });
}
if (!existing.isPin) {
return NextResponse.json({
success: true,
message: 'ยกเลิกการปักหมุดเรียบร้อยแล้ว'
});
}
await db
.update(announcements)
.set({
isPin: false,
updatedBy: session.user.id,
updatedAt: new Date()
})
.where(and(eq(announcements.id, announcementId), eq(announcements.organizationId, organization.id)));
const updated = await getAnnouncementByIdForAccess({
id: announcementId,
organizationId: organization.id,
permissions: session.user.effectivePermissions
});
if (!updated) {
throw new Error('ไม่พบข้อมูลประกาศหลังยกเลิกการปักหมุด');
}
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: AUDIT_ACTION.ANNOUNCEMENT_UNPINNED,
entityName: updated.title,
entityId: updated.id,
oldValue: toAuditValue(serializeAnnouncementInternal(existing)),
newValue: toAuditValue(serializeAnnouncementInternal(updated)),
...requestMetadata
});
return NextResponse.json({
success: true,
message: 'ยกเลิกการปักหมุดเรียบร้อยแล้ว'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'ไม่สามารถยกเลิกการปักหมุดได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,292 @@
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { announcements } from '@/db/schema';
import {
canCreateContent,
canTransitionContent,
getNextContentStatus
} from '@/features/content-approval/lib/workflow';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
getAnnouncementReviewRecipientIds,
listAnnouncementsForAccess,
serializeAnnouncementForAccess,
serializeAnnouncementInternal
} from '@/features/announcements/server/announcement-data';
import {
announcementDraftActionSchema,
announcementDraftSchema
} from '@/features/announcements/schemas/announcement';
import { createContentWorkflowNotification } from '@/features/notifications/server/notification-service';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import {
ANNOUNCEMENT_MAX_FILE_SIZE,
saveAnnouncementAttachment
} from '@/lib/announcement-storage';
import { getDatabaseErrorMessage } from '@/lib/db-errors';
import { db } from '@/lib/db';
const SUPPORTED_ATTACHMENT_TYPES = new Set([
'application/pdf',
'image/jpeg',
'image/png',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
]);
function normalizeDateValue(value: FormDataEntryValue | null) {
if (typeof value !== 'string') return '';
return value.trim();
}
function parseBooleanValue(value: FormDataEntryValue | null) {
return value === 'true';
}
function validateAttachment(file: File | null) {
if (!file || file.size === 0) return null;
if (!SUPPORTED_ATTACHMENT_TYPES.has(file.type)) {
throw new Error('รองรับไฟล์แนบ PDF, JPG, JPEG, PNG, DOC, DOCX, XLS และ XLSX เท่านั้น');
}
if (file.size > ANNOUNCEMENT_MAX_FILE_SIZE) {
throw new Error('ไฟล์แนบต้องมีขนาดไม่เกิน 10MB');
}
return file;
}
async function parseAnnouncementFormData(request: NextRequest) {
const formData = await request.formData();
const rawValues = {
title: String(formData.get('title') ?? ''),
content: String(formData.get('content') ?? ''),
startDate: normalizeDateValue(formData.get('startDate')),
endDate: normalizeDateValue(formData.get('endDate')) || undefined,
removeAttachment: parseBooleanValue(formData.get('removeAttachment'))
};
const parsedValues = announcementDraftSchema.safeParse(rawValues);
if (!parsedValues.success) {
throw new Error(parsedValues.error.issues[0]?.message ?? 'ข้อมูลประกาศไม่ถูกต้อง');
}
const parsedAction = announcementDraftActionSchema.safeParse(
String(formData.get('action') ?? 'draft')
);
if (!parsedAction.success) {
throw new Error('คำสั่งการบันทึกประกาศไม่ถูกต้อง');
}
return {
action: parsedAction.data,
values: parsedValues.data,
attachment: validateAttachment(formData.get('attachment') as File | null)
};
}
export async function GET(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess();
const { searchParams } = request.nextUrl;
const result = await listAnnouncementsForAccess({
organizationId: organization.id,
permissions: session.user.effectivePermissions,
currentUserId: session.user.id,
filters: {
page: Number(searchParams.get('page') ?? 1),
limit: Number(searchParams.get('limit') ?? 10),
search: searchParams.get('search') ?? undefined,
status: (searchParams.get('status') as
| 'draft'
| 'pending_review'
| 'approved'
| 'published'
| 'returned'
| 'rejected'
| 'archived'
| null) ?? undefined,
pin: (searchParams.get('pin') as 'all' | 'pinned' | null) ?? undefined,
sort: searchParams.get('sort') ?? undefined
}
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'โหลดประกาศเรียบร้อยแล้ว',
total_announcements: result.total,
offset: result.offset,
limit: result.limit,
announcements: result.announcements
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถโหลดประกาศได้' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess();
const requestMetadata = getRequestMetadata(request);
const { action, values, attachment } = await parseAnnouncementFormData(request);
const permissions = session.user.effectivePermissions;
if (!canCreateContent('announcement', permissions)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const wantsSubmit = action === 'submit';
if (
wantsSubmit &&
!canTransitionContent({
module: 'announcement',
action: 'submit',
currentStatus: 'draft',
permissions,
isOwner: true
})
) {
return NextResponse.json({ message: 'ไม่มีสิทธิ์ส่งประกาศเพื่อตรวจสอบ' }, { status: 403 });
}
const initialStatus = wantsSubmit ? getNextContentStatus('submit') : 'draft';
const [created] = await db
.insert(announcements)
.values({
organizationId: organization.id,
title: values.title.trim(),
content: values.content.trim(),
startDate: new Date(values.startDate),
endDate: values.endDate?.trim() ? new Date(values.endDate) : null,
isPin: false,
status: initialStatus,
submittedAt: wantsSubmit ? new Date() : null,
submittedBy: wantsSubmit ? session.user.id : null,
createdBy: session.user.id,
updatedBy: session.user.id
})
.returning();
let record = created;
if (attachment) {
const storedAttachment = await saveAnnouncementAttachment({
file: attachment,
organizationId: organization.id,
announcementId: created.id
});
[record] = await db
.update(announcements)
.set({
attachmentFileName: storedAttachment.fileName,
attachmentFileType: storedAttachment.fileType,
attachmentFileSize: storedAttachment.fileSize,
attachmentStorageKey: storedAttachment.storageKey,
attachmentFileUrl: storedAttachment.fileUrl,
updatedBy: session.user.id,
updatedAt: new Date()
})
.where(eq(announcements.id, created.id))
.returning();
}
const creatorName = session.user.name ?? session.user.email ?? null;
const announcementRow = {
...record,
createdByName: creatorName,
updatedByName: creatorName
};
const auditAnnouncement = serializeAnnouncementInternal(announcementRow);
const responseAnnouncement = serializeAnnouncementForAccess({
row: announcementRow,
permissions,
currentUserId: session.user.id
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: AUDIT_ACTION.ANNOUNCEMENT_CREATE,
entityName: record.title,
entityId: record.id,
newValue: toAuditValue(auditAnnouncement),
...requestMetadata
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ANNOUNCEMENTS,
action: wantsSubmit
? AUDIT_ACTION.ANNOUNCEMENT_SUBMIT
: AUDIT_ACTION.ANNOUNCEMENT_DRAFT_SAVED,
entityName: record.title,
entityId: record.id,
newValue: toAuditValue(auditAnnouncement),
...requestMetadata
});
if (wantsSubmit) {
const reviewerIds = await getAnnouncementReviewRecipientIds({
organizationId: organization.id,
excludeUserIds: [session.user.id]
});
await Promise.all(
reviewerIds.map((userId) =>
createContentWorkflowNotification({
organizationId: organization.id,
userId,
title: `มีประกาศใหม่รอตรวจสอบ: ${record.title}`,
message: 'มีการส่งประกาศเข้าสู่คิวตรวจสอบ กรุณาเปิดเพื่อตรวจสอบและอนุมัติ',
type: 'CONTENT_SUBMITTED',
referenceId: record.id,
referenceType: 'content_submission'
})
)
);
}
return NextResponse.json(
{
success: true,
time: new Date().toISOString(),
message: wantsSubmit
? 'ส่งประกาศเพื่อตรวจสอบเรียบร้อยแล้ว'
: 'บันทึกร่างประกาศเรียบร้อยแล้ว',
announcement: responseAnnouncement
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error && !error.message.includes('Database')) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
console.error('Failed to create announcement', error);
return NextResponse.json({ message: getDatabaseErrorMessage(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import {
getAuditActionLabel,
getAuditModuleLabel
} from '@/features/audit-logs/constants/audit-log-options';
import { auditLogFilterSchema } from '@/features/audit-logs/schemas/audit-log';
import { listAuditLogsForOrganization } from '@/features/audit-logs/server/audit-log-data';
import { AuthError, requireIT } from '@/lib/auth/session';
function escapeCsvCell(value: string) {
return `"${value.replaceAll('"', '""')}"`;
}
export async function GET(request: NextRequest) {
try {
const { organization } = await requireIT();
const filters = auditLogFilterSchema.parse({
page: 1,
limit: 1000,
search: request.nextUrl.searchParams.get('search') ?? undefined,
user: request.nextUrl.searchParams.get('user') ?? undefined,
module: request.nextUrl.searchParams.get('module') ?? undefined,
action: request.nextUrl.searchParams.get('action') ?? undefined,
dateFrom: request.nextUrl.searchParams.get('dateFrom') ?? undefined,
dateTo: request.nextUrl.searchParams.get('dateTo') ?? undefined
});
const result = await listAuditLogsForOrganization({
organizationId: organization.id,
filters
});
const rows = [
['วันที่', 'ผู้ใช้งาน', 'อีเมล', 'โมดูล', 'รายการ', 'รายละเอียด', 'IP'],
...result.auditLogs.map((log) => [
new Intl.DateTimeFormat('th-TH', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(log.created_at)),
log.user_name ?? 'ระบบ',
log.user_email ?? '',
getAuditModuleLabel(log.module_name),
getAuditActionLabel(log.action),
log.details,
log.ip_address ?? ''
])
];
const csv = rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n');
return new NextResponse(`\uFEFF${csv}`, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="audit-logs.csv"'
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถส่งออกประวัติการใช้งานได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditLogFilterSchema } from '@/features/audit-logs/schemas/audit-log';
import { listAuditLogsForOrganization } from '@/features/audit-logs/server/audit-log-data';
import { AuthError, requireIT } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireIT();
const filters = auditLogFilterSchema.parse({
page: request.nextUrl.searchParams.get('page') ?? undefined,
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
search: request.nextUrl.searchParams.get('search') ?? undefined,
user: request.nextUrl.searchParams.get('user') ?? undefined,
module: request.nextUrl.searchParams.get('module') ?? undefined,
action: request.nextUrl.searchParams.get('action') ?? undefined,
dateFrom: request.nextUrl.searchParams.get('dateFrom') ?? undefined,
dateTo: request.nextUrl.searchParams.get('dateTo') ?? undefined,
sort: request.nextUrl.searchParams.get('sort') ?? undefined
});
const result = await listAuditLogsForOrganization({
organizationId: organization.id,
filters
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'โหลดประวัติการใช้งานเรียบร้อยแล้ว',
total_audit_logs: result.total,
offset: result.offset,
limit: result.limit,
audit_logs: result.auditLogs
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถโหลดประวัติการใช้งานได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,6 @@
import { handlers } from '@/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
await request.json().catch(() => null);
return NextResponse.json(
{
message: 'Self-service sign-up is disabled. Please contact your administrator.'
},
{ status: 403 }
);
}

View File

@@ -0,0 +1,149 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { courses } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import type { CourseMutationPayload } from '@/features/courses/api/types';
import {
getCourseByIdForOrganization,
normalizeCoursePayload,
serializeCourse
} from '@/features/courses/server/course-data';
import { AuthError, requireHRD } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
export async function GET(request: NextRequest, { params }: Params) {
try {
const { organization } = await requireHRD();
const { id } = await params;
const course = await getCourseByIdForOrganization({
id: Number(id),
organizationId: organization.id
});
if (!course) {
return NextResponse.json(
{ success: false, message: `Course with ID ${id} not found` },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: `Course with ID ${id} found`,
course: serializeCourse(course)
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load course' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { organization, session } = await requireHRD();
const requestMetadata = getRequestMetadata(request);
const { id } = await params;
const body = normalizeCoursePayload((await request.json()) as CourseMutationPayload);
const existingCourse = await getCourseByIdForOrganization({
id: Number(id),
organizationId: organization.id
});
if (!existingCourse) {
return NextResponse.json(
{ success: false, message: `Course with ID ${id} not found` },
{ status: 404 }
);
}
const [course] = await db
.update(courses)
.set({
courseCode: body.courseCode,
name: body.name,
category: body.category,
organizer: body.organizer,
standardHours: body.standardHours,
certificateValidityMonths: body.certificateValidityMonths,
isRequired: body.isRequired,
certificateRequired: existingCourse.certificateRequired,
trainingCost: body.trainingCost,
description: body.description,
isActive: body.isActive,
updatedAt: new Date()
})
.where(and(eq(courses.id, Number(id)), eq(courses.organizationId, organization.id)))
.returning();
const serializedCourse = serializeCourse(course);
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.COURSE_MASTER,
action: AUDIT_ACTION.UPDATE,
entityName: course.name,
entityId: course.id,
oldValue: toAuditValue(serializeCourse(existingCourse)),
newValue: toAuditValue(serializedCourse),
...requestMetadata
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Course updated successfully',
course: serializedCourse
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to update course' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { organization } = await requireHRD();
const { id } = await params;
const [deletedCourse] = await db
.delete(courses)
.where(and(eq(courses.id, Number(id)), eq(courses.organizationId, organization.id)))
.returning({ id: courses.id });
if (!deletedCourse) {
return NextResponse.json(
{ success: false, message: `Course with ID ${id} not found` },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
message: 'Course deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete course' }, { status: 500 });
}
}

View File

@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { courses } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import type { CourseMutationPayload } from '@/features/courses/api/types';
import {
listCoursesForOrganization,
normalizeCoursePayload,
serializeCourse
} from '@/features/courses/server/course-data';
import { isHRD } from '@/lib/auth/roles';
import { AuthError, requireHRD, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
function getSingleFilterParam(
searchParams: URLSearchParams,
primaryKey: string,
legacyKey?: string
) {
const rawValue = searchParams.get(primaryKey) ?? (legacyKey ? searchParams.get(legacyKey) : null);
const normalized = rawValue
?.split(/[.,]/)
.map((value) => value.trim())
.find(Boolean);
return normalized && normalized !== 'all' ? normalized : undefined;
}
export async function GET(request: NextRequest) {
try {
const access = await requireOrganizationAccess();
const { searchParams } = request.nextUrl;
const canManageCourses = isHRD({
businessRole: access.session.user.businessRole,
systemRole: access.session.user.systemRole,
membershipRole: access.membership.role,
role: access.session.user.role
});
const requestedStatus = getSingleFilterParam(searchParams, 'status', 'statuses');
const result = await listCoursesForOrganization({
organizationId: access.organization.id,
filters: {
page: Number(searchParams.get('page') ?? 1),
limit: Number(searchParams.get('limit') ?? 10),
search: searchParams.get('search') ?? undefined,
category: getSingleFilterParam(searchParams, 'category', 'categories'),
status: canManageCourses ? requestedStatus : 'active',
sort: searchParams.get('sort') ?? undefined
}
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'โหลดหลักสูตรเรียบร้อยแล้ว',
total_courses: result.total,
offset: result.offset,
limit: result.limit,
courses: result.courses
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถโหลดหลักสูตรได้' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireHRD();
const requestMetadata = getRequestMetadata(request);
const body = normalizeCoursePayload((await request.json()) as CourseMutationPayload);
const [course] = await db
.insert(courses)
.values({
organizationId: organization.id,
courseCode: body.courseCode,
name: body.name,
category: body.category,
organizer: body.organizer,
standardHours: body.standardHours,
certificateValidityMonths: body.certificateValidityMonths,
isRequired: body.isRequired,
certificateRequired: true,
trainingCost: body.trainingCost,
description: body.description,
isActive: body.isActive
})
.returning();
const serializedCourse = serializeCourse(course);
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.COURSE_MASTER,
action: AUDIT_ACTION.CREATE,
entityName: course.name,
entityId: course.id,
newValue: toAuditValue(serializedCourse),
...requestMetadata
});
return NextResponse.json(
{
success: true,
time: new Date().toISOString(),
message: 'สร้างหลักสูตรเรียบร้อยแล้ว',
course: serializedCourse
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถสร้างหลักสูตรได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { AuthError, requireHRD } from '@/lib/auth/session';
import {
getEmployeeTargetForOrganization,
upsertEmployeeTargetForOrganization
} from '@/features/employee-directory/server/employee-directory-data';
const targetSchema = z
.object({
year: z.number().int().min(2000),
totalHours: z.number().min(0),
kHours: z.number().min(0),
sHours: z.number().min(0),
aHours: z.number().min(0)
})
.refine((value) => Number((value.kHours + value.sHours + value.aHours).toFixed(2)) === Number(value.totalHours.toFixed(2)), {
message: 'ผลรวม K/S/A ต้องเท่ากับชั่วโมงรวม',
path: ['totalHours']
});
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization } = await requireHRD();
const params = await context.params;
const employeeId = Number(params.id);
const year = Number(request.nextUrl.searchParams.get('year') ?? new Date().getUTCFullYear());
const target = await getEmployeeTargetForOrganization({
organizationId: organization.id,
employeeId,
year
});
if (!target) {
return NextResponse.json({ message: 'Employee not found' }, { status: 404 });
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Employee target loaded successfully',
target
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load employee target' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization, session } = await requireHRD();
const params = await context.params;
const employeeId = Number(params.id);
const body = await request.json();
const parsed = targetSchema.safeParse({
year: Number(body.year),
totalHours: Number(body.totalHours),
kHours: Number(body.kHours),
sHours: Number(body.sHours),
aHours: Number(body.aHours)
});
if (!parsed.success) {
return NextResponse.json(
{
message: parsed.error.issues[0]?.message ?? 'Invalid target payload'
},
{ status: 400 }
);
}
const target = await upsertEmployeeTargetForOrganization({
organizationId: organization.id,
employeeId,
actorUserId: session.user.id,
values: parsed.data
});
if (!target) {
return NextResponse.json({ message: 'Employee not found' }, { status: 404 });
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Employee target updated successfully',
target
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to update employee target' }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthError, requireHRD } from '@/lib/auth/session';
import { listEmployeeDirectoryForOrganization } from '@/features/employee-directory/server/employee-directory-data';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireHRD();
const { searchParams } = request.nextUrl;
const result = await listEmployeeDirectoryForOrganization({
organizationId: organization.id,
filters: {
page: Number(searchParams.get('page') ?? 1),
limit: Number(searchParams.get('limit') ?? 10),
search: searchParams.get('search') ?? undefined,
company: searchParams.get('company') ?? undefined,
department: searchParams.get('department') ?? undefined,
position: searchParams.get('position') ?? undefined,
status: searchParams.get('status') ?? undefined,
sort: searchParams.get('sort') ?? undefined
}
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Employee directory loaded successfully',
total_employees: result.total,
offset: result.offset,
limit: result.limit,
employees: result.employees,
options: result.options
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load employee directory' }, { status: 500 });
}
}

View File

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { getDatabaseErrorMessage } from '@/lib/db-errors';
import {
getEmployeeByIdForOrganization,
softDeleteEmployeeForOrganization,
updateEmployeeForOrganization
} from '@/features/employees/server/employee-data';
import type { EmployeeMutationPayload } from '@/features/employees/api/types';
export async function GET(
_request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization } = await requireOrganizationAccess();
const params = await context.params;
const employee = await getEmployeeByIdForOrganization({
id: Number(params.id),
organizationId: organization.id
});
if (!employee) {
return NextResponse.json({ message: 'Employee not found' }, { status: 404 });
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Employee loaded successfully',
employee
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: getDatabaseErrorMessage(error) }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization } = await requireOrganizationAccess();
const params = await context.params;
const body = (await request.json()) as EmployeeMutationPayload;
const employee = await updateEmployeeForOrganization({
id: Number(params.id),
organizationId: organization.id,
payload: body
});
if (!employee) {
return NextResponse.json({ message: 'Employee not found' }, { status: 404 });
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Employee updated successfully',
employee
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: getDatabaseErrorMessage(error) }, { status: 500 });
}
}
export async function DELETE(
_request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization } = await requireOrganizationAccess();
const params = await context.params;
await softDeleteEmployeeForOrganization({
id: Number(params.id),
organizationId: organization.id
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Employee deactivated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: getDatabaseErrorMessage(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { getDatabaseErrorMessage } from '@/lib/db-errors';
import {
createEmployeeForOrganization,
getEmployeeOptionsForOrganization,
listEmployeesForOrganization
} from '@/features/employees/server/employee-data';
import type { EmployeeMutationPayload } from '@/features/employees/api/types';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess();
const mode = request.nextUrl.searchParams.get('mode');
if (mode === 'options') {
const employees = await getEmployeeOptionsForOrganization(organization.id);
return NextResponse.json({ employees });
}
const { searchParams } = request.nextUrl;
const result = await listEmployeesForOrganization({
organizationId: organization.id,
filters: {
page: Number(searchParams.get('page') ?? 1),
limit: Number(searchParams.get('limit') ?? 10),
search: searchParams.get('search') ?? undefined,
statuses: searchParams.get('statuses') ?? undefined,
sort: searchParams.get('sort') ?? undefined
}
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Employees loaded successfully',
total_employees: result.total,
offset: result.offset,
limit: result.limit,
employees: result.employees
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: getDatabaseErrorMessage(error) }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess();
const body = (await request.json()) as EmployeeMutationPayload;
const employee = await createEmployeeForOrganization({
organizationId: organization.id,
payload: body
});
if (!employee) {
return NextResponse.json({ message: 'Unable to create employee' }, { status: 400 });
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Employee created successfully',
employee
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: getDatabaseErrorMessage(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,782 @@
import { and, eq, sql } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import * as XLSX from 'xlsx';
import { departments, importJobs, organizations, positions, trainingRecords } from '@/db/schema';
import {
normalizeImportedStatus,
thaiImportEmployeeHeaders,
thaiImportTemplateHeaders,
thaiImportTrainingHeaders,
thaiTrainingTypeLabels
} from '@/constants/thai-labels';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
findEmployeeByCodeForOrganization,
syncUsersForEmployeeCode,
upsertEmployeeMasterFromImport
} from '@/features/employees/server/employee-identity-linking';
import { createImportCompletedNotification } from '@/features/notifications/server/notification-service';
import {
normalizeTrainingTypeForDb,
type LegacyTrainingType
} from '@/features/training-records/constants/training-record-options';
import { isMissingTrainingRecordMinutesStorage } from '@/features/training-records/server/training-record-data';
import { decimalHoursToMinutes } from '@/features/training-records/utils/time-utils';
import { AuthError, requireHRD } from '@/lib/auth/session';
import { db } from '@/lib/db';
type ImportedTrainingRecordInput = {
courseName: string;
category: 'K' | 'S' | 'A';
hours: number;
trainingType: LegacyTrainingType;
trainingDate: Date;
};
type EmployeeImportGroup = {
employeeCode: string;
employeeName: string;
email: string;
company: string;
department: string;
position: string;
status: string;
rowNumbers: number[];
trainingRecords: Array<{
rowNumber: number;
data: ImportedTrainingRecordInput;
}>;
};
type ImportError = {
row: number;
message: string;
employee_code?: string;
};
const EMPLOYEE_HEADERS = {
employeeCode: thaiImportEmployeeHeaders[0],
employeeName: thaiImportEmployeeHeaders[1],
email: thaiImportEmployeeHeaders[2],
company: thaiImportEmployeeHeaders[3],
department: thaiImportEmployeeHeaders[4],
position: thaiImportEmployeeHeaders[5],
status: thaiImportEmployeeHeaders[6]
} as const;
const TRAINING_HEADERS = {
courseName: thaiImportTrainingHeaders[0],
category: thaiImportTrainingHeaders[1],
hours: thaiImportTrainingHeaders[2],
trainingType: thaiImportTrainingHeaders[3],
trainingDate: thaiImportTrainingHeaders[4]
} as const;
const allowedTrainingTypeKeys = new Set([
'INTERNAL_TRAINING',
'EXTERNAL_TRAINING',
'ONLINE_COURSE',
'OJT',
'internal',
'external',
'online',
'onsite'
]);
function rowError(row: number, message: string, employeeCode?: string): ImportError {
return { row, message, employee_code: employeeCode };
}
function normalizeText(value: unknown) {
return String(value ?? '').trim();
}
function normalizeComparableText(value: string) {
return value.trim().toLowerCase();
}
function parseImportYear(value: FormDataEntryValue | null) {
const currentYear = new Date().getUTCFullYear();
const rawValue = typeof value === 'string' ? value.trim() : '';
if (!rawValue) {
return currentYear;
}
const parsedYear = Number(rawValue);
if (!Number.isFinite(parsedYear)) {
return currentYear;
}
return parsedYear >= 2400 ? parsedYear - 543 : parsedYear;
}
function parseOptionalNumber(value: unknown) {
const normalized = normalizeText(value);
if (!normalized) {
return null;
}
const parsed = Number(normalized.replaceAll(',', ''));
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
function normalizeImportedCalendarYear(year: number) {
return year >= 2400 ? year - 543 : year;
}
function createDefaultTrainingDate(importYear: number) {
return new Date(Date.UTC(importYear, 0, 1));
}
function parseImportedTrainingDate(value: unknown, importYear: number) {
if (value instanceof Date && Number.isFinite(value.getTime())) {
return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()));
}
if (typeof value === 'number' && Number.isFinite(value)) {
const parsed = XLSX.SSF.parse_date_code(value);
if (parsed) {
return new Date(Date.UTC(parsed.y, parsed.m - 1, parsed.d));
}
}
const normalized = normalizeText(value);
if (!normalized) {
return createDefaultTrainingDate(importYear);
}
const isoMatch = normalized.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
if (isoMatch) {
const year = normalizeImportedCalendarYear(Number(isoMatch[1]));
const month = Number(isoMatch[2]);
const day = Number(isoMatch[3]);
const parsedDate = new Date(Date.UTC(year, month - 1, day));
if (Number.isFinite(parsedDate.getTime())) {
return parsedDate;
}
}
const localMatch = normalized.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{2,4})$/);
if (localMatch) {
const day = Number(localMatch[1]);
const month = Number(localMatch[2]);
const rawYear = Number(localMatch[3]);
const year = normalizeImportedCalendarYear(rawYear < 100 ? rawYear + 2000 : rawYear);
const parsedDate = new Date(Date.UTC(year, month - 1, day));
if (Number.isFinite(parsedDate.getTime())) {
return parsedDate;
}
}
const parsedByRuntime = new Date(normalized);
if (Number.isFinite(parsedByRuntime.getTime())) {
return new Date(
Date.UTC(
parsedByRuntime.getUTCFullYear(),
parsedByRuntime.getUTCMonth(),
parsedByRuntime.getUTCDate()
)
);
}
return null;
}
function parseImportedTrainingType(value: unknown) {
const normalized = normalizeText(value);
if (!normalized) {
return 'internal' as const;
}
if (allowedTrainingTypeKeys.has(normalized)) {
return normalizeTrainingTypeForDb(normalized);
}
const matchedLabelEntry = Object.entries(thaiTrainingTypeLabels).find(
([key, label]) =>
allowedTrainingTypeKeys.has(key) &&
normalizeComparableText(label) === normalizeComparableText(normalized)
);
if (matchedLabelEntry) {
return normalizeTrainingTypeForDb(matchedLabelEntry[0]);
}
const comparable = normalizeComparableText(normalized).replaceAll('-', ' ').replaceAll('_', ' ');
if (comparable.includes('online')) {
return 'online';
}
if (comparable.includes('ojt') || comparable.includes('on the job')) {
return 'onsite';
}
if (comparable.includes('external') || comparable.includes('public')) {
return 'external';
}
if (comparable.includes('internal')) {
return 'internal';
}
return null;
}
function parseImportedCategory(value: unknown) {
const normalized = normalizeText(value);
if (!normalized) {
return null;
}
const uppercased = normalized.toUpperCase();
if (uppercased === 'K' || uppercased.startsWith('KNOWLEDGE')) {
return 'K' as const;
}
if (uppercased === 'S' || uppercased.startsWith('SKILL')) {
return 'S' as const;
}
if (uppercased === 'A' || uppercased.startsWith('ATTITUDE')) {
return 'A' as const;
}
return null;
}
function validateImportedTrainingRecord(
rowNumber: number,
row: Record<string, unknown>,
employeeCode: string,
importYear: number
) {
const courseName = normalizeText(row[TRAINING_HEADERS.courseName]);
const rawCategory = row[TRAINING_HEADERS.category];
const rawHours = row[TRAINING_HEADERS.hours];
const rawTrainingType = row[TRAINING_HEADERS.trainingType];
const rawTrainingDate = row[TRAINING_HEADERS.trainingDate];
const hasAnyTrainingValue = [courseName, rawCategory, rawHours, rawTrainingType, rawTrainingDate].some(
(value) => normalizeText(value).length > 0
);
if (!hasAnyTrainingValue) {
return { trainingRecord: null };
}
if (!courseName) {
return { error: rowError(rowNumber, 'กรุณาระบุชื่อหลักสูตร', employeeCode) };
}
const category = parseImportedCategory(rawCategory);
if (!category) {
return {
error: rowError(rowNumber, 'หมวดหมู่ต้องเป็น K, S หรือ A', employeeCode)
};
}
const hours = parseOptionalNumber(rawHours);
if (hours === null) {
return { error: rowError(rowNumber, 'กรุณาระบุชั่วโมงอบรม', employeeCode) };
}
if (Number.isNaN(hours)) {
return { error: rowError(rowNumber, 'ชั่วโมงอบรมต้องเป็นตัวเลข', employeeCode) };
}
if ((hours ?? 0) <= 0) {
return { error: rowError(rowNumber, 'ชั่วโมงอบรมต้องมากกว่า 0', employeeCode) };
}
const trainingType = parseImportedTrainingType(rawTrainingType);
if (!trainingType) {
return {
error: rowError(
rowNumber,
'ประเภทการอบรมต้องเป็น internal, external, online หรือ OJT',
employeeCode
)
};
}
const trainingDate = parseImportedTrainingDate(rawTrainingDate, importYear);
if (!trainingDate) {
return { error: rowError(rowNumber, 'วันที่อบรมไม่ถูกต้อง', employeeCode) };
}
return {
trainingRecord: {
courseName,
category,
hours: Number(hours.toFixed(2)),
trainingType,
trainingDate
} satisfies ImportedTrainingRecordInput
};
}
async function resolvePendingDepartmentId(organizationId: string, name: string) {
const existing = await db.query.departments.findFirst({
where: and(eq(departments.organizationId, organizationId), eq(departments.name, name))
});
if (existing) {
return { id: existing.id, pending: existing.pendingMasterReview };
}
const [department] = await db
.insert(departments)
.values({
organizationId,
name,
isActive: false,
pendingMasterReview: true
})
.returning({ id: departments.id });
return { id: department.id, pending: true };
}
async function resolvePendingPositionId(organizationId: string, name: string) {
const existing = await db.query.positions.findFirst({
where: and(eq(positions.organizationId, organizationId), eq(positions.name, name))
});
if (existing) {
return { id: existing.id, pending: existing.pendingMasterReview };
}
const [position] = await db
.insert(positions)
.values({
organizationId,
name,
isActive: false,
pendingMasterReview: true
})
.returning({ id: positions.id });
return { id: position.id, pending: true };
}
function validateHeaders(rows: Record<string, unknown>[]) {
const availableHeaders = rows[0] ? Object.keys(rows[0]) : [];
const requiredHeaders = [...thaiImportEmployeeHeaders, ...thaiImportTrainingHeaders];
return requiredHeaders.every((header) => availableHeaders.includes(header));
}
function hasMatchingEmployeeIdentity(
group: EmployeeImportGroup,
row: Omit<EmployeeImportGroup, 'rowNumbers' | 'trainingRecords'>
) {
return (
group.employeeName === row.employeeName &&
group.email === row.email &&
group.company === row.company &&
group.department === row.department &&
group.position === row.position &&
group.status === row.status
);
}
async function insertImportedTrainingRecord({
organizationId,
userId,
employeeId,
sessionUserId,
trainingRecord
}: {
organizationId: string;
userId: string | null;
employeeId: number;
sessionUserId: string;
trainingRecord: ImportedTrainingRecordInput;
}) {
const minutes = decimalHoursToMinutes(trainingRecord.hours);
const baseValues = {
organizationId,
userId,
employeeId,
courseId: null,
courseName: trainingRecord.courseName,
trainingDate: trainingRecord.trainingDate,
trainingType: trainingRecord.trainingType,
hours: trainingRecord.hours,
approvedHours: trainingRecord.hours,
category: trainingRecord.category,
organizer: null,
note: 'imported from employee excel',
approvalStatus: 'approved' as const,
submittedByUserId: sessionUserId,
reviewedByUserId: sessionUserId,
reviewedBy: sessionUserId,
reviewedAt: new Date(),
createdBy: sessionUserId
};
try {
await db.insert(trainingRecords).values({
...baseValues,
submittedMinutes: minutes,
approvedMinutes: minutes
});
} catch (error) {
if (!isMissingTrainingRecordMinutesStorage(error)) {
throw error;
}
await db.insert(trainingRecords).values(baseValues);
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireHRD();
const requestMetadata = getRequestMetadata(request);
const formData = await request.formData();
const file = formData.get('file');
const importYear = parseImportYear(formData.get('year'));
if (!(file instanceof File)) {
return NextResponse.json(
{ message: 'กรุณาแนบไฟล์ Excel สำหรับนำเข้า' },
{ status: 400 }
);
}
if (!file.name.toLowerCase().endsWith('.xlsx')) {
return NextResponse.json(
{ message: 'ระบบรองรับเฉพาะไฟล์ .xlsx เท่านั้น' },
{ status: 400 }
);
}
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: 'array' });
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
if (!worksheet) {
return NextResponse.json({ message: 'ไม่พบข้อมูลในไฟล์ที่อัปโหลด' }, { status: 400 });
}
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(worksheet, { defval: '' });
if (rows.length === 0) {
return NextResponse.json(
{ message: 'ไม่พบข้อมูลพนักงานในไฟล์ที่อัปโหลด' },
{ status: 400 }
);
}
if (!validateHeaders(rows)) {
return NextResponse.json(
{
message: `หัวตารางไม่ถูกต้อง กรุณาใช้เทมเพลตภาษาไทยของระบบ: ${thaiImportTemplateHeaders.join(', ')}`
},
{ status: 400 }
);
}
const errors: ImportError[] = [];
const groupedRows = new Map<string, EmployeeImportGroup>();
const emailOwners = new Map<string, string>();
for (const [index, row] of rows.entries()) {
const rowNumber = index + 2;
const employeeCode = normalizeText(row[EMPLOYEE_HEADERS.employeeCode]);
const employeeName = normalizeText(row[EMPLOYEE_HEADERS.employeeName]);
const email = normalizeText(row[EMPLOYEE_HEADERS.email]).toLowerCase();
const company = normalizeText(row[EMPLOYEE_HEADERS.company]);
const department = normalizeText(row[EMPLOYEE_HEADERS.department]);
const position = normalizeText(row[EMPLOYEE_HEADERS.position]);
const status = normalizeText(row[EMPLOYEE_HEADERS.status]);
if (!employeeCode || !employeeName || !company || !department || !position || !status) {
errors.push(
rowError(rowNumber, 'กรุณากรอกข้อมูลที่จำเป็นให้ครบทุกช่อง', employeeCode)
);
continue;
}
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.push(rowError(rowNumber, 'รูปแบบอีเมลไม่ถูกต้อง', employeeCode));
continue;
}
if (normalizeImportedStatus(status) === null) {
errors.push(rowError(rowNumber, 'สถานะต้องเป็น ใช้งาน หรือ ไม่ใช้งาน', employeeCode));
continue;
}
const trainingValidation = validateImportedTrainingRecord(
rowNumber,
row,
employeeCode,
importYear
);
if ('error' in trainingValidation && trainingValidation.error) {
errors.push(trainingValidation.error);
continue;
}
if (email) {
const ownedByEmployeeCode = emailOwners.get(email);
if (ownedByEmployeeCode && ownedByEmployeeCode !== employeeCode) {
errors.push(rowError(rowNumber, 'พบอีเมลซ้ำกับพนักงานอีกคนในไฟล์นำเข้า', employeeCode));
continue;
}
emailOwners.set(email, employeeCode);
}
const existingGroup = groupedRows.get(employeeCode);
const identityRow = {
employeeCode,
employeeName,
email,
company,
department,
position,
status
};
if (existingGroup) {
if (!hasMatchingEmployeeIdentity(existingGroup, identityRow)) {
errors.push(
rowError(rowNumber, 'พบรหัสพนักงานซ้ำแต่ข้อมูลพนักงานไม่ตรงกัน', employeeCode)
);
continue;
}
existingGroup.rowNumbers.push(rowNumber);
if (trainingValidation.trainingRecord) {
existingGroup.trainingRecords.push({
rowNumber,
data: trainingValidation.trainingRecord
});
}
continue;
}
groupedRows.set(employeeCode, {
...identityRow,
rowNumbers: [rowNumber],
trainingRecords: trainingValidation.trainingRecord
? [
{
rowNumber,
data: trainingValidation.trainingRecord
}
]
: []
});
}
let createdEmployees = 0;
let updatedEmployees = 0;
let linkedUsers = 0;
let importedTrainingRecords = 0;
const pendingCompanyNames = new Set<string>();
for (const group of groupedRows.values()) {
try {
const isActive = normalizeImportedStatus(group.status);
if (isActive === null) {
for (const rowNumber of group.rowNumbers) {
errors.push(rowError(rowNumber, 'สถานะไม่ถูกต้อง', group.employeeCode));
}
continue;
}
const [departmentResult, positionResult, existingEmployee] = await Promise.all([
resolvePendingDepartmentId(organization.id, group.department),
resolvePendingPositionId(organization.id, group.position),
findEmployeeByCodeForOrganization({
organizationId: organization.id,
employeeCode: group.employeeCode
})
]);
if (group.company !== organization.name) {
pendingCompanyNames.add(group.company);
}
const employee = await upsertEmployeeMasterFromImport({
organizationId: organization.id,
employeeCode: group.employeeCode,
fullName: group.employeeName,
email: group.email,
companyName: group.company,
departmentId: departmentResult.id,
positionId: positionResult.id,
isActive
});
if (existingEmployee) {
updatedEmployees += 1;
} else {
createdEmployees += 1;
}
const syncedUserIds = await syncUsersForEmployeeCode({
organizationId: organization.id,
employeeCode: group.employeeCode,
employeeId: employee.id
});
linkedUsers += syncedUserIds.length;
for (const trainingRecordRow of group.trainingRecords) {
try {
await insertImportedTrainingRecord({
organizationId: organization.id,
userId: syncedUserIds[0] ?? null,
employeeId: employee.id,
sessionUserId: session.user.id,
trainingRecord: trainingRecordRow.data
});
importedTrainingRecords += 1;
} catch (error) {
errors.push(
rowError(
trainingRecordRow.rowNumber,
error instanceof Error
? error.message
: 'เกิดข้อผิดพลาดระหว่างบันทึกประวัติการอบรม',
group.employeeCode
)
);
}
}
} catch (error) {
for (const rowNumber of group.rowNumbers) {
errors.push(
rowError(
rowNumber,
error instanceof Error ? error.message : 'เกิดข้อผิดพลาดระหว่างนำเข้าข้อมูล',
group.employeeCode
)
);
}
}
}
const pendingCompanies = pendingCompanyNames.size;
await db
.update(organizations)
.set({
pendingMasterReview: pendingCompanies > 0,
updatedAt: new Date()
})
.where(eq(organizations.id, organization.id));
const [pendingDepartmentsRow] = await db
.select({ value: sql<number>`count(*)::int` })
.from(departments)
.where(
and(eq(departments.organizationId, organization.id), eq(departments.pendingMasterReview, true))
);
const [pendingPositionsRow] = await db
.select({ value: sql<number>`count(*)::int` })
.from(positions)
.where(and(eq(positions.organizationId, organization.id), eq(positions.pendingMasterReview, true)));
const failedRows = errors.length;
const successRows = Math.max(rows.length - failedRows, 0);
const summary = {
total_rows: rows.length,
created_employees: createdEmployees,
updated_employees: updatedEmployees,
linked_users: linkedUsers,
imported_training_records: importedTrainingRecords,
failed_rows: failedRows,
pending_companies: pendingCompanies,
pending_departments: pendingDepartmentsRow?.value ?? 0,
pending_positions: pendingPositionsRow?.value ?? 0
};
const [job] = await db
.insert(importJobs)
.values({
organizationId: organization.id,
importType: 'employee_excel',
fileName: file.name,
totalRows: rows.length,
successRows,
failedRows,
status: 'completed',
createdBy: session.user.id
})
.returning({ id: importJobs.id });
await createImportCompletedNotification({
organizationId: organization.id,
userId: session.user.id,
totalRows: rows.length,
successRows,
failedRows,
referenceId: job.id
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.EMPLOYEE_IMPORT,
action: AUDIT_ACTION.IMPORT,
entityName: file.name,
entityId: job.id,
newValue: toAuditValue({
file_name: file.name,
import_year: importYear,
summary
}),
...requestMetadata
});
return NextResponse.json({
success: true,
message:
failedRows === 0
? 'นำเข้าข้อมูลพนักงานและประวัติการอบรมเรียบร้อยแล้ว'
: 'นำเข้าข้อมูลสำเร็จบางส่วน กรุณาตรวจสอบรายการที่ผิดพลาด',
import_job_id: job.id,
summary,
errors
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json(
{ message: 'ไม่สามารถนำเข้าข้อมูลพนักงานได้' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,69 @@
import { NextResponse } from 'next/server';
import * as XLSX from 'xlsx';
import {
thaiImportEmployeeHeaders,
thaiImportTemplateHeaders,
thaiImportTrainingHeaders
} from '@/constants/thai-labels';
import { AuthError, requireHRD } from '@/lib/auth/session';
export async function GET() {
try {
await requireHRD();
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.aoa_to_sheet([
[...thaiImportTemplateHeaders],
[
'EMP-001',
'สมชาย ใจดี',
'somchai@example.com',
'บริษัทตัวอย่าง',
'ฝ่ายผลิต',
'ช่างเทคนิค',
'ใช้งาน',
'React Basic',
'K',
6,
'INTERNAL_TRAINING',
'2026-01-15'
],
[
'EMP-001',
'สมชาย ใจดี',
'somchai@example.com',
'บริษัทตัวอย่าง',
'ฝ่ายผลิต',
'ช่างเทคนิค',
'ใช้งาน',
'Next.js',
'S',
8,
'ONLINE_COURSE',
'2026-02-20'
]
]);
worksheet['!cols'] = [
...thaiImportEmployeeHeaders.map(() => ({ wch: 20 })),
...thaiImportTrainingHeaders.map(() => ({ wch: 22 }))
];
XLSX.utils.book_append_sheet(workbook, worksheet, 'Employees');
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': 'attachment; filename="employee-import-template-th.xlsx"'
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถสร้างเทมเพลตนำเข้าได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,371 @@
import { and, eq, inArray, ne } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { departments, memberships, organizations, positions, users } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import { createMasterReviewNotification } from '@/features/notifications/server/notification-service';
import { AuthError, requireHRD } from '@/lib/auth/session';
import { db } from '@/lib/db';
async function getOrganizationUserIds(organizationId: string) {
const rows = await db
.select({ id: memberships.userId })
.from(memberships)
.where(eq(memberships.organizationId, organizationId));
return rows.map((row) => row.id);
}
async function updateOrganizationPendingCompanyFlag(organizationId: string, organizationName: string) {
const [remaining] = await db
.selectDistinct({ id: users.companyName })
.from(memberships)
.innerJoin(users, eq(memberships.userId, users.id))
.where(and(eq(memberships.organizationId, organizationId), ne(users.companyName, organizationName)));
await db
.update(organizations)
.set({
pendingMasterReview: Boolean(remaining),
updatedAt: new Date()
})
.where(eq(organizations.id, organizationId));
}
export async function GET() {
try {
const { organization } = await requireHRD();
const [companyRows, departmentRows, positionRows] = await Promise.all([
db
.selectDistinct({
id: users.companyName,
name: users.companyName
})
.from(memberships)
.innerJoin(users, eq(memberships.userId, users.id))
.where(and(eq(memberships.organizationId, organization.id), ne(users.companyName, organization.name))),
db
.select()
.from(departments)
.where(and(eq(departments.organizationId, organization.id), eq(departments.pendingMasterReview, true))),
db
.select()
.from(positions)
.where(and(eq(positions.organizationId, organization.id), eq(positions.pendingMasterReview, true)))
]);
const companies = await Promise.all(
companyRows
.filter((row) => row.id && row.name)
.map(async (row) => {
const affectedRows = await db
.select({ value: users.id })
.from(memberships)
.innerJoin(users, eq(memberships.userId, users.id))
.where(and(eq(memberships.organizationId, organization.id), eq(users.companyName, row.name!)));
return {
id: row.id!,
type: 'company' as const,
name: row.name!,
affected_count: affectedRows.length,
status: 'pending' as const
};
})
);
const departmentsPayload = await Promise.all(
departmentRows.map(async (row) => {
const affectedRows = await db
.select({ value: users.id })
.from(memberships)
.innerJoin(users, eq(memberships.userId, users.id))
.where(and(eq(memberships.organizationId, organization.id), eq(users.departmentId, row.id)));
return {
id: String(row.id),
type: 'department' as const,
name: row.name,
affected_count: affectedRows.length,
status: 'pending' as const
};
})
);
const positionsPayload = await Promise.all(
positionRows.map(async (row) => {
const affectedRows = await db
.select({ value: users.id })
.from(memberships)
.innerJoin(users, eq(memberships.userId, users.id))
.where(and(eq(memberships.organizationId, organization.id), eq(users.positionId, row.id)));
return {
id: String(row.id),
type: 'position' as const,
name: row.name,
affected_count: affectedRows.length,
status: 'pending' as const
};
})
);
return NextResponse.json({
success: true,
message: 'โหลดรายการรอตรวจสอบข้อมูลหลักเรียบร้อยแล้ว',
companies,
departments: departmentsPayload,
positions: positionsPayload
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถโหลดรายการรอตรวจสอบได้' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest) {
try {
const { organization, session } = await requireHRD();
const requestMetadata = getRequestMetadata(request);
const body = (await request.json()) as {
entityType: 'company' | 'department' | 'position';
action: 'approve' | 'reject';
id: string;
};
const scopedUserIds = await getOrganizationUserIds(organization.id);
if (body.entityType === 'company') {
if (body.action === 'approve') {
await db
.update(organizations)
.set({
name: body.id,
updatedAt: new Date()
})
.where(eq(organizations.id, organization.id));
await updateOrganizationPendingCompanyFlag(organization.id, body.id);
await createMasterReviewNotification({
organizationId: organization.id,
userId: session.user.id,
masterType: 'บริษัท',
entityName: body.id,
action: 'approve',
referenceId: body.id
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.MASTER_REVIEW,
action: AUDIT_ACTION.APPROVE,
entityName: body.id,
entityId: body.id,
newValue: toAuditValue({
entityType: body.entityType,
action: body.action,
name: body.id
}),
...requestMetadata
});
return NextResponse.json({
success: true,
message: 'อนุมัติชื่อบริษัทเรียบร้อยแล้ว'
});
}
if (scopedUserIds.length > 0) {
await db
.update(users)
.set({
companyName: organization.name,
updatedAt: new Date()
})
.where(and(inArray(users.id, scopedUserIds), eq(users.companyName, body.id)));
}
await updateOrganizationPendingCompanyFlag(organization.id, organization.name);
await createMasterReviewNotification({
organizationId: organization.id,
userId: session.user.id,
masterType: 'บริษัท',
entityName: body.id,
action: 'reject',
referenceId: body.id
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.MASTER_REVIEW,
action: AUDIT_ACTION.REJECT,
entityName: body.id,
entityId: body.id,
newValue: toAuditValue({
entityType: body.entityType,
action: body.action,
name: body.id
}),
...requestMetadata
});
return NextResponse.json({
success: true,
message: 'ไม่อนุมัติชื่อบริษัทเรียบร้อยแล้ว'
});
}
const targetId = Number(body.id);
const targetRow =
body.entityType === 'department'
? await db.query.departments.findFirst({
where: and(eq(departments.id, targetId), eq(departments.organizationId, organization.id))
})
: await db.query.positions.findFirst({
where: and(eq(positions.id, targetId), eq(positions.organizationId, organization.id))
});
if (!targetRow) {
return NextResponse.json({ message: 'ไม่พบข้อมูลหลักที่ต้องการตรวจสอบ' }, { status: 404 });
}
if (body.entityType === 'department') {
if (body.action === 'approve') {
await db
.update(departments)
.set({
isActive: true,
pendingMasterReview: false,
updatedAt: new Date()
})
.where(and(eq(departments.id, targetId), eq(departments.organizationId, organization.id)));
} else {
if (scopedUserIds.length > 0) {
await db
.update(users)
.set({
departmentId: null,
updatedAt: new Date()
})
.where(and(inArray(users.id, scopedUserIds), eq(users.departmentId, targetId)));
}
await db
.update(departments)
.set({
isActive: false,
pendingMasterReview: false,
updatedAt: new Date()
})
.where(and(eq(departments.id, targetId), eq(departments.organizationId, organization.id)));
}
await createMasterReviewNotification({
organizationId: organization.id,
userId: session.user.id,
masterType: 'แผนก',
entityName: targetRow.name,
action: body.action,
referenceId: targetId
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.MASTER_REVIEW,
action: body.action === 'approve' ? AUDIT_ACTION.APPROVE : AUDIT_ACTION.REJECT,
entityName: targetRow.name,
entityId: targetId,
newValue: toAuditValue({
entityType: body.entityType,
action: body.action,
name: targetRow.name
}),
...requestMetadata
});
return NextResponse.json({
success: true,
message: body.action === 'approve' ? 'อนุมัติแผนกเรียบร้อยแล้ว' : 'ไม่อนุมัติแผนกเรียบร้อยแล้ว'
});
}
if (body.action === 'approve') {
await db
.update(positions)
.set({
isActive: true,
pendingMasterReview: false,
updatedAt: new Date()
})
.where(and(eq(positions.id, targetId), eq(positions.organizationId, organization.id)));
} else {
if (scopedUserIds.length > 0) {
await db
.update(users)
.set({
positionId: null,
updatedAt: new Date()
})
.where(and(inArray(users.id, scopedUserIds), eq(users.positionId, targetId)));
}
await db
.update(positions)
.set({
isActive: false,
pendingMasterReview: false,
updatedAt: new Date()
})
.where(and(eq(positions.id, targetId), eq(positions.organizationId, organization.id)));
}
await createMasterReviewNotification({
organizationId: organization.id,
userId: session.user.id,
masterType: 'ตำแหน่ง',
entityName: targetRow.name,
action: body.action,
referenceId: targetId
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.MASTER_REVIEW,
action: body.action === 'approve' ? AUDIT_ACTION.APPROVE : AUDIT_ACTION.REJECT,
entityName: targetRow.name,
entityId: targetId,
newValue: toAuditValue({
entityType: body.entityType,
action: body.action,
name: targetRow.name
}),
...requestMetadata
});
return NextResponse.json({
success: true,
message: body.action === 'approve' ? 'อนุมัติตำแหน่งเรียบร้อยแล้ว' : 'ไม่อนุมัติตำแหน่งเรียบร้อยแล้ว'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถอัปเดตรายการรอตรวจสอบได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { markNotificationAsRead } from '@/features/notifications/server/notification-service';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = { params: Promise<{ id: string }> };
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { organization, session } = await requireOrganizationAccess();
const { id } = await params;
const updated = await markNotificationAsRead({
organizationId: organization.id,
notificationId: Number(id),
actorUserId: session.user.id,
request
});
if (!updated) {
return NextResponse.json({ message: 'ไม่พบการแจ้งเตือน' }, { status: 404 });
}
return NextResponse.json({
success: true,
message: 'อ่านการแจ้งเตือนแล้ว'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถอัปเดตการแจ้งเตือนได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { markAllNotificationsAsRead } from '@/features/notifications/server/notification-service';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function PATCH(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess();
const count = await markAllNotificationsAsRead({
organizationId: organization.id,
actorUserId: session.user.id,
request
});
return NextResponse.json({
success: true,
message: count > 0 ? 'อ่านการแจ้งเตือนทั้งหมดแล้ว' : 'ไม่มีการแจ้งเตือนที่ยังไม่ได้อ่าน'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถอัปเดตการแจ้งเตือนได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { listNotificationsForUser } from '@/features/notifications/server/notification-data';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const notificationFilterSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
search: z.string().trim().optional(),
status: z.enum(['all', 'unread', 'read']).default('all'),
type: z
.enum([
'TRAINING_APPROVED',
'TRAINING_REJECTED',
'ANNOUNCEMENT_PUBLISHED',
'IMPORT_COMPLETED',
'MASTER_REVIEW_APPROVED',
'MASTER_REVIEW_REJECTED',
'SYSTEM'
])
.optional()
});
export async function GET(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess();
const filters = notificationFilterSchema.parse({
page: request.nextUrl.searchParams.get('page') ?? undefined,
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
search: request.nextUrl.searchParams.get('search') ?? undefined,
status: request.nextUrl.searchParams.get('status') ?? undefined,
type: request.nextUrl.searchParams.get('type') ?? undefined
});
const result = await listNotificationsForUser({
organizationId: organization.id,
userId: session.user.id,
filters
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'โหลดการแจ้งเตือนเรียบร้อยแล้ว',
total_notifications: result.total,
unread_count: result.unreadCount,
offset: result.offset,
limit: result.limit,
notifications: result.notifications
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถโหลดการแจ้งเตือนได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { buildOnlineLessonAttachmentResponse } from '@/features/online-lessons/server/online-lesson-attachment-response';
type Params = { params: Promise<{ id: string }> };
export const runtime = 'nodejs';
function parseLessonId(value: string) {
const lessonId = Number(value);
return Number.isNaN(lessonId) ? null : lessonId;
}
export async function GET(_request: NextRequest, { params }: Params) {
const { id } = await params;
const lessonId = parseLessonId(id);
if (!lessonId) {
return NextResponse.json({ message: 'Invalid online lesson attachment request' }, { status: 400 });
}
return buildOnlineLessonAttachmentResponse({
lessonId,
disposition: 'attachment'
});
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { buildOnlineLessonAttachmentResponse } from '@/features/online-lessons/server/online-lesson-attachment-response';
type Params = { params: Promise<{ id: string }> };
export const runtime = 'nodejs';
function parseLessonId(value: string) {
const lessonId = Number(value);
return Number.isNaN(lessonId) ? null : lessonId;
}
export async function GET(_request: NextRequest, { params }: Params) {
const { id } = await params;
const lessonId = parseLessonId(id);
if (!lessonId) {
return NextResponse.json({ message: 'Invalid online lesson attachment request' }, { status: 400 });
}
return buildOnlineLessonAttachmentResponse({
lessonId,
disposition: 'inline'
});
}

View File

@@ -0,0 +1,690 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { onlineLessons } from '@/db/schema';
import {
canDeleteContent,
canEditContent,
canReadAllContent,
canTransitionContent,
getNextContentStatus
} from '@/features/content-approval/lib/workflow';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
onlineLessonDraftActionSchema,
onlineLessonDraftSchema,
validateOnlineLessonReadyForReview
} from '@/features/online-lessons/schemas/online-lesson';
import { normalizeOnlineLessonCategory } from '@/features/online-lessons/constants/categories';
import {
getOnlineLessonByIdForAccess,
getOnlineLessonReviewRecipientIds,
serializeOnlineLesson
} from '@/features/online-lessons/server/online-lesson-data';
import { createContentWorkflowNotification } from '@/features/notifications/server/notification-service';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { getDatabaseErrorMessage } from '@/lib/db-errors';
import { db } from '@/lib/db';
import {
deleteStoredOnlineLessonAsset,
ONLINE_LESSON_ACCEPTED_ATTACHMENT_TYPES,
ONLINE_LESSON_ACCEPTED_THUMBNAIL_TYPES,
ONLINE_LESSON_ACCEPTED_VIDEO_TYPES,
ONLINE_LESSON_MAX_ATTACHMENT_SIZE,
ONLINE_LESSON_MAX_THUMBNAIL_SIZE,
ONLINE_LESSON_MAX_VIDEO_SIZE,
saveOnlineLessonAttachment,
saveOnlineLessonThumbnail,
saveOnlineLessonVideo
} from '@/lib/online-lesson-storage';
function parseId(value: string) {
const id = Number(value);
if (Number.isNaN(id)) {
throw new Error('รหัสบทเรียนออนไลน์ไม่ถูกต้อง');
}
return id;
}
function parseBooleanValue(value: FormDataEntryValue | null) {
return value === 'true';
}
function normalizeTextValue(value: FormDataEntryValue | null) {
if (typeof value !== 'string') return '';
return value.trim();
}
function buildWorkflowMessage(comment: string | null, fallback: string) {
return comment ? `${fallback} หมายเหตุ: ${comment}` : fallback;
}
function validateOptionalFile({
file,
acceptedTypes,
maxSize,
errorMessage,
sizeErrorMessage
}: {
file: File | null;
acceptedTypes: readonly string[];
maxSize: number;
errorMessage: string;
sizeErrorMessage: string;
}) {
if (!file || file.size === 0) return null;
if (!acceptedTypes.includes(file.type)) {
throw new Error(errorMessage);
}
if (file.size > maxSize) {
throw new Error(sizeErrorMessage);
}
return file;
}
async function parseOnlineLessonFormData(request: NextRequest) {
const formData = await request.formData();
const rawValues = {
title: String(formData.get('title') ?? ''),
description: String(formData.get('description') ?? ''),
category: normalizeOnlineLessonCategory(String(formData.get('category') ?? '')),
videoUrl: normalizeTextValue(formData.get('videoUrl')) || undefined
};
const parsedValues = onlineLessonDraftSchema.safeParse(rawValues);
if (!parsedValues.success) {
throw new Error(parsedValues.error.issues[0]?.message ?? 'ข้อมูลบทเรียนออนไลน์ไม่ถูกต้อง');
}
const parsedAction = onlineLessonDraftActionSchema.safeParse(
String(formData.get('action') ?? 'draft')
);
if (!parsedAction.success) {
throw new Error('คำสั่งการบันทึกบทเรียนไม่ถูกต้อง');
}
return {
action: parsedAction.data,
values: parsedValues.data,
removeVideoFile: parseBooleanValue(formData.get('removeVideoFile')),
removeAttachment: parseBooleanValue(formData.get('removeAttachment')),
removeThumbnail: parseBooleanValue(formData.get('removeThumbnail')),
videoFile: validateOptionalFile({
file: formData.get('videoFile') as File | null,
acceptedTypes: ONLINE_LESSON_ACCEPTED_VIDEO_TYPES,
maxSize: ONLINE_LESSON_MAX_VIDEO_SIZE,
errorMessage: 'รองรับไฟล์วิดีโอ MP4, WEBM และ MOV เท่านั้น',
sizeErrorMessage: 'ไฟล์วิดีโอต้องมีขนาดไม่เกิน 200MB'
}),
attachmentFile: validateOptionalFile({
file: formData.get('attachmentFile') as File | null,
acceptedTypes: ONLINE_LESSON_ACCEPTED_ATTACHMENT_TYPES,
maxSize: ONLINE_LESSON_MAX_ATTACHMENT_SIZE,
errorMessage: 'ไฟล์แนบไม่อยู่ในรูปแบบที่รองรับ',
sizeErrorMessage: 'ไฟล์แนบต้องมีขนาดไม่เกิน 20MB'
}),
thumbnailFile: validateOptionalFile({
file: formData.get('thumbnailFile') as File | null,
acceptedTypes: ONLINE_LESSON_ACCEPTED_THUMBNAIL_TYPES,
maxSize: ONLINE_LESSON_MAX_THUMBNAIL_SIZE,
errorMessage: 'รูปปกต้องเป็น JPG, PNG หรือ WEBP เท่านั้น',
sizeErrorMessage: 'รูปปกต้องมีขนาดไม่เกิน 5MB'
})
};
}
export async function GET(
_request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization, session } = await requireOrganizationAccess();
const { id: rawId } = await context.params;
const id = parseId(rawId);
const row = await getOnlineLessonByIdForAccess({
id,
organizationId: organization.id,
permissions: session.user.effectivePermissions
});
if (!row) {
return NextResponse.json({ message: 'ไม่พบบทเรียนออนไลน์' }, { status: 404 });
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'โหลดรายละเอียดบทเรียนออนไลน์เรียบร้อยแล้ว',
online_lesson: serializeOnlineLesson(row, {
includeInternalReviewData: canReadAllContent(
'online_lesson',
session.user.effectivePermissions
)
})
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'ไม่สามารถโหลดรายละเอียดบทเรียนออนไลน์ได้' }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization, session } = await requireOrganizationAccess();
const requestMetadata = getRequestMetadata(request);
const { id: rawId } = await context.params;
const id = parseId(rawId);
const [existing] = await db
.select()
.from(onlineLessons)
.where(and(eq(onlineLessons.id, id), eq(onlineLessons.organizationId, organization.id)))
.limit(1);
if (!existing) {
return NextResponse.json({ message: 'ไม่พบบทเรียนออนไลน์' }, { status: 404 });
}
const permissions = session.user.effectivePermissions;
const isOwner = existing.createdBy === session.user.id;
const previousSerialized = serializeOnlineLesson({
...existing,
createdByName: null,
updatedByName: null
});
const contentType = request.headers.get('content-type') ?? '';
let nextStatus = existing.status;
let reviewComment = existing.reviewComment;
let multipartAction: 'draft' | 'submit' | null = null;
let successMessage = 'อัปเดตบทเรียนออนไลน์เรียบร้อยแล้ว';
if (contentType.includes('multipart/form-data')) {
if (
!canEditContent({
module: 'online_lesson',
permissions,
status: existing.status,
isOwner
})
) {
return NextResponse.json({ message: 'ไม่มีสิทธิ์แก้ไขบทเรียนในสถานะปัจจุบัน' }, { status: 403 });
}
const {
action,
values,
removeVideoFile,
removeAttachment,
removeThumbnail,
videoFile,
attachmentFile,
thumbnailFile
} = await parseOnlineLessonFormData(request);
multipartAction = action;
const hasExistingThumbnail = Boolean(existing.thumbnailUrl && !removeThumbnail);
const canSubmitAgain = canTransitionContent({
module: 'online_lesson',
action: 'submit',
currentStatus: existing.status,
permissions,
isOwner
});
if (action === 'submit') {
if (!canSubmitAgain) {
return NextResponse.json({ message: 'ไม่มีสิทธิ์ส่งบทเรียนเพื่อตรวจสอบ' }, { status: 403 });
}
validateOnlineLessonReadyForReview({
values,
hasThumbnailSource: Boolean(thumbnailFile || hasExistingThumbnail)
});
nextStatus = getNextContentStatus('submit');
successMessage = 'ส่งบทเรียนเพื่อตรวจสอบเรียบร้อยแล้ว';
} else {
nextStatus = existing.status;
successMessage = canSubmitAgain
? 'บันทึกบทเรียนฉบับร่างเรียบร้อยแล้ว'
: 'บันทึกการแก้ไขบทเรียนเรียบร้อยแล้ว';
}
await db
.update(onlineLessons)
.set({
title: values.title.trim(),
description: values.description?.trim() || null,
category: values.category?.trim() || null,
videoUrl: values.videoUrl?.trim() || null,
status: nextStatus,
submittedAt: action === 'submit' ? new Date() : existing.submittedAt,
submittedBy: action === 'submit' ? session.user.id : existing.submittedBy,
updatedBy: session.user.id,
updatedAt: new Date(),
...(removeVideoFile && !videoFile
? {
videoFileName: null,
videoFileType: null,
videoFileSize: null,
videoStorageKey: null,
videoFileUrl: null
}
: {}),
...(removeAttachment && !attachmentFile
? {
attachmentFileName: null,
attachmentFileType: null,
attachmentFileSize: null,
attachmentStorageKey: null,
attachmentFileUrl: null
}
: {}),
...(removeThumbnail && !thumbnailFile
? {
thumbnailFileName: null,
thumbnailFileType: null,
thumbnailFileSize: null,
thumbnailStorageKey: null,
thumbnailUrl: null
}
: {})
})
.where(and(eq(onlineLessons.id, id), eq(onlineLessons.organizationId, organization.id)));
if (removeVideoFile && !videoFile && existing.videoStorageKey) {
await deleteStoredOnlineLessonAsset(existing.videoStorageKey);
}
if (removeAttachment && !attachmentFile && existing.attachmentStorageKey) {
await deleteStoredOnlineLessonAsset(existing.attachmentStorageKey);
}
if (removeThumbnail && !thumbnailFile && existing.thumbnailStorageKey) {
await deleteStoredOnlineLessonAsset(existing.thumbnailStorageKey);
}
if (videoFile) {
const storedVideo = await saveOnlineLessonVideo({
file: videoFile,
organizationId: organization.id,
lessonId: id
});
await db
.update(onlineLessons)
.set({
videoFileName: storedVideo.fileName,
videoFileType: storedVideo.fileType,
videoFileSize: storedVideo.fileSize,
videoStorageKey: storedVideo.storageKey,
videoFileUrl: storedVideo.fileUrl,
updatedBy: session.user.id,
updatedAt: new Date()
})
.where(and(eq(onlineLessons.id, id), eq(onlineLessons.organizationId, organization.id)));
if (existing.videoStorageKey) {
await deleteStoredOnlineLessonAsset(existing.videoStorageKey);
}
}
if (attachmentFile) {
const storedAttachment = await saveOnlineLessonAttachment({
file: attachmentFile,
organizationId: organization.id,
lessonId: id
});
await db
.update(onlineLessons)
.set({
attachmentFileName: storedAttachment.fileName,
attachmentFileType: storedAttachment.fileType,
attachmentFileSize: storedAttachment.fileSize,
attachmentStorageKey: storedAttachment.storageKey,
attachmentFileUrl: storedAttachment.fileUrl,
updatedBy: session.user.id,
updatedAt: new Date()
})
.where(and(eq(onlineLessons.id, id), eq(onlineLessons.organizationId, organization.id)));
if (existing.attachmentStorageKey) {
await deleteStoredOnlineLessonAsset(existing.attachmentStorageKey);
}
}
if (thumbnailFile) {
const storedThumbnail = await saveOnlineLessonThumbnail({
file: thumbnailFile,
organizationId: organization.id,
lessonId: id
});
await db
.update(onlineLessons)
.set({
thumbnailFileName: storedThumbnail.fileName,
thumbnailFileType: storedThumbnail.fileType,
thumbnailFileSize: storedThumbnail.fileSize,
thumbnailStorageKey: storedThumbnail.storageKey,
thumbnailUrl: storedThumbnail.fileUrl,
updatedBy: session.user.id,
updatedAt: new Date()
})
.where(and(eq(onlineLessons.id, id), eq(onlineLessons.organizationId, organization.id)));
if (existing.thumbnailStorageKey) {
await deleteStoredOnlineLessonAsset(existing.thumbnailStorageKey);
}
}
} else {
const body = (await request.json()) as {
action?: 'submit' | 'approve' | 'return' | 'reject' | 'publish' | 'archive';
status?:
| 'draft'
| 'pending_review'
| 'approved'
| 'published'
| 'returned'
| 'rejected'
| 'archived';
comment?: string;
};
const normalizedAction =
body.action ??
(body.status === 'pending_review'
? 'submit'
: body.status === 'published'
? 'publish'
: body.status === 'archived'
? 'archive'
: undefined);
if (!normalizedAction) {
return NextResponse.json({ message: 'ไม่มีข้อมูลสำหรับอัปเดต' }, { status: 400 });
}
if (
!canTransitionContent({
module: 'online_lesson',
action: normalizedAction,
currentStatus: existing.status,
permissions,
isOwner
})
) {
return NextResponse.json({ message: 'ไม่สามารถเปลี่ยนสถานะบทเรียนได้' }, { status: 403 });
}
nextStatus = getNextContentStatus(normalizedAction);
reviewComment = body.comment?.trim() || existing.reviewComment;
successMessage =
normalizedAction === 'publish'
? 'เผยแพร่บทเรียนเรียบร้อยแล้ว'
: normalizedAction === 'archive'
? 'เก็บบทเรียนเข้าคลังเรียบร้อยแล้ว'
: normalizedAction === 'approve'
? 'อนุมัติบทเรียนเรียบร้อยแล้ว'
: normalizedAction === 'return'
? 'ส่งบทเรียนกลับเพื่อแก้ไขเรียบร้อยแล้ว'
: normalizedAction === 'reject'
? 'ปฏิเสธบทเรียนเรียบร้อยแล้ว'
: 'ส่งบทเรียนเพื่อตรวจสอบเรียบร้อยแล้ว';
await db
.update(onlineLessons)
.set({
status: nextStatus,
submittedAt: normalizedAction === 'submit' ? new Date() : existing.submittedAt,
submittedBy: normalizedAction === 'submit' ? session.user.id : existing.submittedBy,
reviewedAt:
normalizedAction === 'approve' ||
normalizedAction === 'return' ||
normalizedAction === 'reject'
? new Date()
: existing.reviewedAt,
reviewedBy:
normalizedAction === 'approve' ||
normalizedAction === 'return' ||
normalizedAction === 'reject'
? session.user.id
: existing.reviewedBy,
approvedAt: normalizedAction === 'approve' ? new Date() : existing.approvedAt,
approvedBy: normalizedAction === 'approve' ? session.user.id : existing.approvedBy,
isPublished:
normalizedAction === 'publish'
? true
: normalizedAction === 'archive'
? false
: existing.isPublished,
publishedAt:
normalizedAction === 'publish' ? existing.publishedAt ?? new Date() : existing.publishedAt,
publishedBy: normalizedAction === 'publish' ? session.user.id : existing.publishedBy,
reviewComment,
updatedBy: session.user.id,
updatedAt: new Date()
})
.where(and(eq(onlineLessons.id, id), eq(onlineLessons.organizationId, organization.id)));
}
const detailRow = await getOnlineLessonByIdForAccess({
id,
organizationId: organization.id,
permissions: session.user.effectivePermissions
});
if (!detailRow) {
throw new Error('ไม่พบข้อมูลบทเรียนออนไลน์หลังอัปเดต');
}
const serializedLesson = serializeOnlineLesson(detailRow);
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ONLINE_LESSONS,
action: AUDIT_ACTION.ONLINE_LESSON_UPDATE,
entityName: detailRow.title,
entityId: detailRow.id,
oldValue: toAuditValue(previousSerialized),
newValue: toAuditValue(serializedLesson),
...requestMetadata
});
if (multipartAction === 'draft') {
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ONLINE_LESSONS,
action: AUDIT_ACTION.ONLINE_LESSON_DRAFT_SAVED,
entityName: detailRow.title,
entityId: detailRow.id,
oldValue: toAuditValue(previousSerialized),
newValue: toAuditValue(serializedLesson),
...requestMetadata
});
}
if (existing.status !== 'pending_review' && nextStatus === 'pending_review') {
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ONLINE_LESSONS,
action: AUDIT_ACTION.ONLINE_LESSON_SUBMIT,
entityName: detailRow.title,
entityId: detailRow.id,
oldValue: toAuditValue(previousSerialized),
newValue: toAuditValue(serializedLesson),
...requestMetadata
});
const reviewerIds = await getOnlineLessonReviewRecipientIds({
organizationId: organization.id,
excludeUserIds: [session.user.id]
});
await Promise.all(
reviewerIds.map((userId) =>
createContentWorkflowNotification({
organizationId: organization.id,
userId,
title: `มีบทเรียนใหม่รอตรวจสอบ: ${detailRow.title}`,
message: 'มีการส่งบทเรียนออนไลน์เข้าสู่คิวตรวจสอบ กรุณาเปิดเพื่อตรวจสอบและอนุมัติ',
type: 'CONTENT_SUBMITTED',
referenceId: detailRow.id,
referenceType: 'content_submission'
})
)
);
}
if (existing.status !== 'approved' && nextStatus === 'approved') {
await createContentWorkflowNotification({
organizationId: organization.id,
userId: detailRow.createdBy,
title: `บทเรียนได้รับการอนุมัติ: ${detailRow.title}`,
message: buildWorkflowMessage(reviewComment, 'บทเรียนของคุณผ่านการอนุมัติแล้ว'),
type: 'CONTENT_APPROVED',
referenceId: detailRow.id,
referenceType: 'content_approval'
});
}
if (existing.status !== 'returned' && nextStatus === 'returned') {
await createContentWorkflowNotification({
organizationId: organization.id,
userId: detailRow.createdBy,
title: `บทเรียนถูกส่งกลับแก้ไข: ${detailRow.title}`,
message: buildWorkflowMessage(reviewComment, 'กรุณาปรับแก้และส่งตรวจสอบอีกครั้ง'),
type: 'CONTENT_RETURNED',
referenceId: detailRow.id,
referenceType: 'content_approval'
});
}
if (existing.status !== 'rejected' && nextStatus === 'rejected') {
await createContentWorkflowNotification({
organizationId: organization.id,
userId: detailRow.createdBy,
title: `บทเรียนไม่ผ่านการอนุมัติ: ${detailRow.title}`,
message: buildWorkflowMessage(reviewComment, 'กรุณาปรับแก้ก่อนส่งใหม่'),
type: 'CONTENT_REJECTED',
referenceId: detailRow.id,
referenceType: 'content_approval'
});
}
if (existing.status !== 'published' && nextStatus === 'published') {
await createContentWorkflowNotification({
organizationId: organization.id,
userId: detailRow.createdBy,
title: `บทเรียนถูกเผยแพร่แล้ว: ${detailRow.title}`,
message: 'บทเรียนของคุณถูกเผยแพร่เรียบร้อยแล้ว',
type: 'CONTENT_PUBLISHED',
referenceId: detailRow.id,
referenceType: 'content_published'
});
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: successMessage,
online_lesson: serializedLesson
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error && !error.message.includes('Database')) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
console.error('Failed to update online lesson', error);
return NextResponse.json({ message: getDatabaseErrorMessage(error) }, { status: 500 });
}
}
export async function DELETE(
_request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { organization, session } = await requireOrganizationAccess();
const { id: rawId } = await context.params;
const id = parseId(rawId);
const [existing] = await db
.select()
.from(onlineLessons)
.where(and(eq(onlineLessons.id, id), eq(onlineLessons.organizationId, organization.id)))
.limit(1);
if (!existing) {
return NextResponse.json({ message: 'ไม่พบบทเรียนออนไลน์' }, { status: 404 });
}
if (
!canDeleteContent({
module: 'online_lesson',
permissions: session.user.effectivePermissions,
status: existing.status,
isOwner: existing.createdBy === session.user.id
})
) {
return NextResponse.json({ message: 'ไม่มีสิทธิ์ลบบทเรียนนี้' }, { status: 403 });
}
await db
.delete(onlineLessons)
.where(and(eq(onlineLessons.id, id), eq(onlineLessons.organizationId, organization.id)));
for (const storageKey of [
existing.videoStorageKey,
existing.attachmentStorageKey,
existing.thumbnailStorageKey
]) {
if (storageKey) {
await deleteStoredOnlineLessonAsset(storageKey);
}
}
return NextResponse.json({
success: true,
message: 'ลบบทเรียนออนไลน์เรียบร้อยแล้ว'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error && !error.message.includes('Database')) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
console.error('Failed to delete online lesson', error);
return NextResponse.json({ message: getDatabaseErrorMessage(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,377 @@
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { onlineLessons } from '@/db/schema';
import {
canCreateContent,
canTransitionContent,
getNextContentStatus
} from '@/features/content-approval/lib/workflow';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
onlineLessonDraftActionSchema,
onlineLessonDraftSchema,
validateOnlineLessonReadyForReview
} from '@/features/online-lessons/schemas/online-lesson';
import {
getOnlineLessonReviewRecipientIds,
listOnlineLessonsForAccess,
serializeOnlineLesson
} from '@/features/online-lessons/server/online-lesson-data';
import { normalizeOnlineLessonCategory } from '@/features/online-lessons/constants/categories';
import { createContentWorkflowNotification } from '@/features/notifications/server/notification-service';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { getDatabaseErrorMessage } from '@/lib/db-errors';
import { db } from '@/lib/db';
import {
ONLINE_LESSON_ACCEPTED_ATTACHMENT_TYPES,
ONLINE_LESSON_ACCEPTED_THUMBNAIL_TYPES,
ONLINE_LESSON_ACCEPTED_VIDEO_TYPES,
ONLINE_LESSON_MAX_ATTACHMENT_SIZE,
ONLINE_LESSON_MAX_THUMBNAIL_SIZE,
ONLINE_LESSON_MAX_VIDEO_SIZE,
saveOnlineLessonAttachment,
saveOnlineLessonThumbnail,
saveOnlineLessonVideo
} from '@/lib/online-lesson-storage';
function parseBooleanValue(value: FormDataEntryValue | null) {
return value === 'true';
}
function normalizeTextValue(value: FormDataEntryValue | null) {
if (typeof value !== 'string') return '';
return value.trim();
}
function validateOptionalFile({
file,
acceptedTypes,
maxSize,
errorMessage,
sizeErrorMessage
}: {
file: File | null;
acceptedTypes: readonly string[];
maxSize: number;
errorMessage: string;
sizeErrorMessage: string;
}) {
if (!file || file.size === 0) return null;
if (!acceptedTypes.includes(file.type)) {
throw new Error(errorMessage);
}
if (file.size > maxSize) {
throw new Error(sizeErrorMessage);
}
return file;
}
async function parseOnlineLessonFormData(request: NextRequest) {
const formData = await request.formData();
const rawValues = {
title: String(formData.get('title') ?? ''),
description: String(formData.get('description') ?? ''),
category: normalizeOnlineLessonCategory(String(formData.get('category') ?? '')),
videoUrl: normalizeTextValue(formData.get('videoUrl')) || undefined
};
const parsedValues = onlineLessonDraftSchema.safeParse(rawValues);
if (!parsedValues.success) {
throw new Error(parsedValues.error.issues[0]?.message ?? 'ข้อมูลบทเรียนออนไลน์ไม่ถูกต้อง');
}
const parsedAction = onlineLessonDraftActionSchema.safeParse(
String(formData.get('action') ?? 'draft')
);
if (!parsedAction.success) {
throw new Error('คำสั่งการบันทึกบทเรียนไม่ถูกต้อง');
}
return {
action: parsedAction.data,
values: parsedValues.data,
removeVideoFile: parseBooleanValue(formData.get('removeVideoFile')),
removeAttachment: parseBooleanValue(formData.get('removeAttachment')),
removeThumbnail: parseBooleanValue(formData.get('removeThumbnail')),
videoFile: validateOptionalFile({
file: formData.get('videoFile') as File | null,
acceptedTypes: ONLINE_LESSON_ACCEPTED_VIDEO_TYPES,
maxSize: ONLINE_LESSON_MAX_VIDEO_SIZE,
errorMessage: 'รองรับไฟล์วิดีโอ MP4, WEBM และ MOV เท่านั้น',
sizeErrorMessage: 'ไฟล์วิดีโอต้องมีขนาดไม่เกิน 200MB'
}),
attachmentFile: validateOptionalFile({
file: formData.get('attachmentFile') as File | null,
acceptedTypes: ONLINE_LESSON_ACCEPTED_ATTACHMENT_TYPES,
maxSize: ONLINE_LESSON_MAX_ATTACHMENT_SIZE,
errorMessage: 'ไฟล์แนบไม่อยู่ในรูปแบบที่รองรับ',
sizeErrorMessage: 'ไฟล์แนบต้องมีขนาดไม่เกิน 20MB'
}),
thumbnailFile: validateOptionalFile({
file: formData.get('thumbnailFile') as File | null,
acceptedTypes: ONLINE_LESSON_ACCEPTED_THUMBNAIL_TYPES,
maxSize: ONLINE_LESSON_MAX_THUMBNAIL_SIZE,
errorMessage: 'รูปปกต้องเป็น JPG, PNG หรือ WEBP เท่านั้น',
sizeErrorMessage: 'รูปปกต้องมีขนาดไม่เกิน 5MB'
})
};
}
export async function GET(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess();
const { searchParams } = request.nextUrl;
const result = await listOnlineLessonsForAccess({
organizationId: organization.id,
permissions: session.user.effectivePermissions,
filters: {
page: Number(searchParams.get('page') ?? 1),
limit: Number(searchParams.get('limit') ?? 12),
search: searchParams.get('search') ?? undefined,
status: (searchParams.get('status') as
| 'draft'
| 'pending_review'
| 'approved'
| 'published'
| 'returned'
| 'rejected'
| 'archived'
| null) ?? undefined,
category: normalizeOnlineLessonCategory(searchParams.get('category')) ?? undefined
}
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'โหลดบทเรียนออนไลน์เรียบร้อยแล้ว',
total_online_lessons: result.total,
offset: result.offset,
limit: result.limit,
categories: result.categories,
online_lessons: result.onlineLessons
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถโหลดบทเรียนออนไลน์ได้' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess();
const requestMetadata = getRequestMetadata(request);
const { action, values, videoFile, attachmentFile, thumbnailFile } =
await parseOnlineLessonFormData(request);
const permissions = session.user.effectivePermissions;
if (!canCreateContent('online_lesson', permissions)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const wantsSubmit = action === 'submit';
if (
wantsSubmit &&
!canTransitionContent({
module: 'online_lesson',
action: 'submit',
currentStatus: 'draft',
permissions,
isOwner: true
})
) {
return NextResponse.json({ message: 'ไม่มีสิทธิ์ส่งบทเรียนเพื่อตรวจสอบ' }, { status: 403 });
}
if (wantsSubmit) {
validateOnlineLessonReadyForReview({
values,
hasThumbnailSource: Boolean(thumbnailFile)
});
}
const initialStatus = wantsSubmit ? getNextContentStatus('submit') : 'draft';
const [created] = await db
.insert(onlineLessons)
.values({
organizationId: organization.id,
title: values.title.trim(),
description: values.description?.trim() || null,
category: values.category?.trim() || null,
videoUrl: values.videoUrl?.trim() || null,
status: initialStatus,
submittedAt: wantsSubmit ? new Date() : null,
submittedBy: wantsSubmit ? session.user.id : null,
isPublished: false,
publishedAt: null,
createdBy: session.user.id,
updatedBy: session.user.id
})
.returning();
let record = created;
if (videoFile) {
const storedVideo = await saveOnlineLessonVideo({
file: videoFile,
organizationId: organization.id,
lessonId: created.id
});
[record] = await db
.update(onlineLessons)
.set({
videoFileName: storedVideo.fileName,
videoFileType: storedVideo.fileType,
videoFileSize: storedVideo.fileSize,
videoStorageKey: storedVideo.storageKey,
videoFileUrl: storedVideo.fileUrl,
updatedAt: new Date(),
updatedBy: session.user.id
})
.where(eq(onlineLessons.id, created.id))
.returning();
}
if (attachmentFile) {
const storedAttachment = await saveOnlineLessonAttachment({
file: attachmentFile,
organizationId: organization.id,
lessonId: created.id
});
[record] = await db
.update(onlineLessons)
.set({
attachmentFileName: storedAttachment.fileName,
attachmentFileType: storedAttachment.fileType,
attachmentFileSize: storedAttachment.fileSize,
attachmentStorageKey: storedAttachment.storageKey,
attachmentFileUrl: storedAttachment.fileUrl,
updatedAt: new Date(),
updatedBy: session.user.id
})
.where(eq(onlineLessons.id, created.id))
.returning();
}
if (thumbnailFile) {
const storedThumbnail = await saveOnlineLessonThumbnail({
file: thumbnailFile,
organizationId: organization.id,
lessonId: created.id
});
[record] = await db
.update(onlineLessons)
.set({
thumbnailFileName: storedThumbnail.fileName,
thumbnailFileType: storedThumbnail.fileType,
thumbnailFileSize: storedThumbnail.fileSize,
thumbnailStorageKey: storedThumbnail.storageKey,
thumbnailUrl: storedThumbnail.fileUrl,
updatedAt: new Date(),
updatedBy: session.user.id
})
.where(eq(onlineLessons.id, created.id))
.returning();
}
const serializedLesson = serializeOnlineLesson({
...record,
createdByName: session.user.name ?? session.user.email ?? null,
updatedByName: session.user.name ?? session.user.email ?? null
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ONLINE_LESSONS,
action: AUDIT_ACTION.ONLINE_LESSON_CREATE,
entityName: record.title,
entityId: record.id,
newValue: toAuditValue(serializedLesson),
...requestMetadata
});
if (wantsSubmit) {
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ONLINE_LESSONS,
action: AUDIT_ACTION.ONLINE_LESSON_SUBMIT,
entityName: record.title,
entityId: record.id,
newValue: toAuditValue(serializedLesson),
...requestMetadata
});
const reviewerIds = await getOnlineLessonReviewRecipientIds({
organizationId: organization.id,
excludeUserIds: [session.user.id]
});
await Promise.all(
reviewerIds.map((userId) =>
createContentWorkflowNotification({
organizationId: organization.id,
userId,
title: `มีบทเรียนใหม่รอตรวจสอบ: ${record.title}`,
message: 'มีการส่งบทเรียนออนไลน์เข้าสู่คิวตรวจสอบ กรุณาเปิดเพื่อตรวจสอบและอนุมัติ',
type: 'CONTENT_SUBMITTED',
referenceId: record.id,
referenceType: 'content_submission'
})
)
);
} else {
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.ONLINE_LESSONS,
action: AUDIT_ACTION.ONLINE_LESSON_DRAFT_SAVED,
entityName: record.title,
entityId: record.id,
newValue: toAuditValue(serializedLesson),
...requestMetadata
});
}
return NextResponse.json(
{
success: true,
time: new Date().toISOString(),
message: wantsSubmit
? 'ส่งบทเรียนเพื่อตรวจสอบเรียบร้อยแล้ว'
: 'บันทึกบทเรียนฉบับร่างเรียบร้อยแล้ว',
online_lesson: serializedLesson
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error && !error.message.includes('Database')) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
console.error('Failed to create online lesson', error);
return NextResponse.json({ message: getDatabaseErrorMessage(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { ZodError } from 'zod';
import { organizerSchema } from '@/features/organizers/schemas/organizer';
import {
softDeleteOrganizerRecord,
updateOrganizerRecord
} from '@/features/organizers/server/organizer-data';
import { AuthError, requireSuperAdmin } from '@/lib/auth/session';
type RouteContext = {
params: Promise<{
id: string;
}>;
};
export async function PATCH(request: NextRequest, context: RouteContext) {
try {
const session = await requireSuperAdmin();
const { id } = await context.params;
const body = organizerSchema.parse(await request.json());
await updateOrganizerRecord({
organizationId: id,
actorUserId: session.user.id,
payload: body
});
return NextResponse.json({
success: true,
message: 'Organizer updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid organizer data' },
{ status: 400 }
);
}
if (error instanceof Error) {
if (error.message === 'DUPLICATE_NAME') {
return NextResponse.json({ message: 'Organizer name already exists' }, { status: 409 });
}
if (error.message === 'NOT_FOUND') {
return NextResponse.json({ message: 'Organizer not found' }, { status: 404 });
}
if (error.message === 'INACTIVE_ORGANIZER') {
return NextResponse.json(
{ message: 'Inactive organizers cannot be updated' },
{ status: 400 }
);
}
}
return NextResponse.json({ message: 'Unable to update organizer' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, context: RouteContext) {
try {
const session = await requireSuperAdmin();
const { id } = await context.params;
await softDeleteOrganizerRecord({
organizationId: id,
actorUserId: session.user.id
});
return NextResponse.json({
success: true,
message: 'Organizer disabled successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
if (error.message === 'NOT_FOUND') {
return NextResponse.json({ message: 'Organizer not found' }, { status: 404 });
}
if (error.message === 'INACTIVE_ORGANIZER') {
return NextResponse.json({ message: 'Organizer is already inactive' }, { status: 400 });
}
}
return NextResponse.json({ message: 'Unable to disable organizer' }, { status: 500 });
}
}

View File

@@ -0,0 +1,73 @@
import { and, eq, isNull } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { memberships, organizations, users } from '@/db/schema';
import { getDefaultPermissionsForRole, isHRD, isSuperAdmin } from '@/lib/auth/roles';
import { db } from '@/lib/db';
import { AuthError, requireSession } from '@/lib/auth/session';
export async function PATCH(request: NextRequest) {
try {
const session = await requireSession();
const body = (await request.json()) as { organizationId?: string };
const organizationId = body.organizationId;
if (!organizationId) {
return NextResponse.json({ message: 'organizationId is required' }, { status: 400 });
}
const targetOrganization = await db.query.organizations.findFirst({
where: and(
eq(organizations.id, organizationId),
eq(organizations.isActive, true),
isNull(organizations.deletedAt)
)
});
if (!targetOrganization) {
return NextResponse.json({ message: 'Organizer not found or inactive' }, { status: 404 });
}
const membership = await db.query.memberships.findFirst({
where: and(
eq(memberships.userId, session.user.id),
eq(memberships.organizationId, organizationId)
)
});
const canSwitchAnyOrganization =
isSuperAdmin(session.user.systemRole) ||
isHRD({
businessRole: session.user.businessRole,
systemRole: session.user.systemRole,
membershipRole: session.user.membershipRole,
role: session.user.role
});
if (!membership && !canSwitchAnyOrganization) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
if (!membership && canSwitchAnyOrganization) {
await db.insert(memberships).values({
id: crypto.randomUUID(),
userId: session.user.id,
organizationId,
role: 'admin',
permissions: getDefaultPermissionsForRole('admin')
});
}
await db
.update(users)
.set({ activeOrganizationId: organizationId })
.where(eq(users.id, session.user.id));
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to switch organization' }, { status: 500 });
}
}

View File

@@ -0,0 +1,124 @@
import { and, eq, isNull } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { ZodError } from 'zod';
import { memberships, organizations, users } from '@/db/schema';
import { organizerSchema } from '@/features/organizers/schemas/organizer';
import { createOrganizerRecord, listOrganizers } from '@/features/organizers/server/organizer-data';
import { getDefaultPermissionsForRole, isHRD, isSuperAdmin } from '@/lib/auth/roles';
import { db } from '@/lib/db';
import { AuthError, requireSession, requireSuperAdmin } from '@/lib/auth/session';
export async function POST(request: NextRequest) {
try {
const session = await requireSuperAdmin();
const body = organizerSchema.parse(await request.json());
const organizationId = await createOrganizerRecord({
actorUserId: session.user.id,
payload: body
});
await db.insert(memberships).values({
id: crypto.randomUUID(),
userId: session.user.id,
organizationId,
role: 'admin',
permissions: getDefaultPermissionsForRole('admin')
});
await db
.update(users)
.set({ activeOrganizationId: organizationId })
.where(eq(users.id, session.user.id));
return NextResponse.json({
success: true,
organizationId
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid organizer data' },
{ status: 400 }
);
}
if (error instanceof Error && error.message === 'DUPLICATE_NAME') {
return NextResponse.json({ message: 'Organizer name already exists' }, { status: 409 });
}
return NextResponse.json({ message: 'Unable to create organization' }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
try {
const session = await requireSession();
const requestedStatus = request.nextUrl.searchParams.get('status');
const canReadAllOrganizers =
isSuperAdmin(session.user.systemRole) ||
isHRD({
businessRole: session.user.businessRole,
systemRole: session.user.systemRole,
membershipRole: session.user.membershipRole,
role: session.user.role
});
const organizersList = canReadAllOrganizers
? await listOrganizers({
status:
requestedStatus === 'inactive' || requestedStatus === 'all' ? requestedStatus : 'active'
})
: await db
.select({
id: organizations.id,
name: organizations.name,
slug: organizations.slug,
imageUrl: organizations.imageUrl,
plan: organizations.plan,
isActive: organizations.isActive,
deletedAt: organizations.deletedAt,
deletedBy: organizations.deletedBy,
createdBy: organizations.createdBy,
createdAt: organizations.createdAt,
updatedAt: organizations.updatedAt
})
.from(organizations)
.innerJoin(memberships, eq(memberships.organizationId, organizations.id))
.where(
and(
eq(memberships.userId, session.user.id),
eq(organizations.isActive, true),
isNull(organizations.deletedAt)
)
)
.then((rows) =>
rows.map((row) => ({
id: row.id,
name: row.name,
slug: row.slug,
plan: row.plan,
is_active: row.isActive,
deleted_at: row.deletedAt ? row.deletedAt.toISOString() : null,
deleted_by: row.deletedBy,
created_by: row.createdBy,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString()
}))
);
return NextResponse.json({
success: true,
organizers: organizersList
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load organizers' }, { status: 500 });
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { listPermissionAuditLogsForOrganization } from '@/features/permission-management/server/permission-audit-data';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireSuperAdminOrganizationAccess();
const result = await listPermissionAuditLogsForOrganization({
organizationId: organization.id,
filters: {
page: Number(request.nextUrl.searchParams.get('page') ?? 1),
limit: Number(request.nextUrl.searchParams.get('limit') ?? 10),
search: request.nextUrl.searchParams.get('search') ?? undefined,
user: request.nextUrl.searchParams.get('user') ?? undefined,
action: request.nextUrl.searchParams.get('action') ?? undefined,
dateFrom: request.nextUrl.searchParams.get('dateFrom') ?? undefined,
dateTo: request.nextUrl.searchParams.get('dateTo') ?? undefined,
sort: request.nextUrl.searchParams.get('sort') ?? undefined
}
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Loaded permission audit logs successfully',
total_permission_audit_logs: result.total,
offset: result.offset,
limit: result.limit,
permission_audit_logs: result.permissionAuditLogs
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load permission audit logs' }, { status: 500 });
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { setPermissionTemplateActiveState } from '@/features/permission-management/server/permission-template-data';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { organization, session } = await requireSuperAdminOrganizationAccess();
const templateId = Number((await params).id);
const body = (await request.json().catch(() => ({}))) as { reason?: string };
await setPermissionTemplateActiveState({
organizationId: organization.id,
actorUserId: session.user.id,
templateId,
isActive: true,
reason: body.reason
});
return NextResponse.json({
success: true,
message: 'Permission template activated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to activate permission template' }, { status: 500 });
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { clonePermissionTemplateForOrganization } from '@/features/permission-management/server/permission-template-data';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { organization, session } = await requireSuperAdminOrganizationAccess();
const templateId = Number((await params).id);
const body = (await request.json().catch(() => ({}))) as { reason?: string };
const clonedId = await clonePermissionTemplateForOrganization({
organizationId: organization.id,
actorUserId: session.user.id,
templateId,
reason: body.reason
});
return NextResponse.json({
success: true,
message: 'Permission template cloned successfully',
template_id: clonedId
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error && error.message === 'NOT_FOUND') {
return NextResponse.json({ message: 'Permission template not found' }, { status: 404 });
}
return NextResponse.json({ message: 'Unable to clone permission template' }, { status: 500 });
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { setPermissionTemplateActiveState } from '@/features/permission-management/server/permission-template-data';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { organization, session } = await requireSuperAdminOrganizationAccess();
const templateId = Number((await params).id);
const body = (await request.json().catch(() => ({}))) as { reason?: string };
await setPermissionTemplateActiveState({
organizationId: organization.id,
actorUserId: session.user.id,
templateId,
isActive: false,
reason: body.reason
});
return NextResponse.json({
success: true,
message: 'Permission template deactivated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to deactivate permission template' }, { status: 500 });
}
}

View File

@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server';
import {
getPermissionTemplateById,
softDeletePermissionTemplate,
updatePermissionTemplateForOrganization
} from '@/features/permission-management/server/permission-template-data';
import { permissionTemplateSchema } from '@/features/permission-management/schemas/permission-template';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
function readTemplateId(value: string) {
const templateId = Number(value);
if (!Number.isInteger(templateId) || templateId <= 0) {
throw new Error('INVALID_TEMPLATE_ID');
}
return templateId;
}
export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { organization } = await requireSuperAdminOrganizationAccess();
const templateId = readTemplateId((await params).id);
const permissionTemplate = await getPermissionTemplateById({
organizationId: organization.id,
templateId
});
return NextResponse.json({
success: true,
permission_template: permissionTemplate
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error && error.message === 'NOT_FOUND') {
return NextResponse.json({ message: 'Permission template not found' }, { status: 404 });
}
return NextResponse.json({ message: 'Unable to load permission template' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { organization, session } = await requireSuperAdminOrganizationAccess();
const templateId = readTemplateId((await params).id);
const body = permissionTemplateSchema.parse(await request.json());
await updatePermissionTemplateForOrganization({
organizationId: organization.id,
actorUserId: session.user.id,
templateId,
payload: body
});
return NextResponse.json({
success: true,
message: 'Permission template updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error && error.message === 'NOT_FOUND') {
return NextResponse.json({ message: 'Permission template not found' }, { status: 404 });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update permission template' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { organization, session } = await requireSuperAdminOrganizationAccess();
const templateId = readTemplateId((await params).id);
const body = (await request.json().catch(() => ({}))) as { reason?: string };
await softDeletePermissionTemplate({
organizationId: organization.id,
actorUserId: session.user.id,
templateId,
reason: body.reason
});
return NextResponse.json({
success: true,
message: 'Permission template deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (
error instanceof Error &&
(error.message === 'NOT_FOUND' || error.message === 'SYSTEM_TEMPLATE_PROTECTED')
) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete permission template' }, { status: 500 });
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import {
createPermissionTemplateForOrganization,
listPermissionTemplatesForOrganization
} from '@/features/permission-management/server/permission-template-data';
import { permissionTemplateSchema } from '@/features/permission-management/schemas/permission-template';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireSuperAdminOrganizationAccess();
const result = await listPermissionTemplatesForOrganization({
organizationId: organization.id,
filters: {
page: Number(request.nextUrl.searchParams.get('page') ?? 1),
limit: Number(request.nextUrl.searchParams.get('limit') ?? 10),
search: request.nextUrl.searchParams.get('search') ?? undefined,
status: request.nextUrl.searchParams.get('status') ?? undefined,
sort: request.nextUrl.searchParams.get('sort') ?? undefined
}
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Loaded permission templates successfully',
total_permission_templates: result.total,
offset: result.offset,
limit: result.limit,
permission_templates: result.permissionTemplates
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load permission templates' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireSuperAdminOrganizationAccess();
const body = permissionTemplateSchema.parse(await request.json());
const templateId = await createPermissionTemplateForOrganization({
organizationId: organization.id,
actorUserId: session.user.id,
payload: body
});
return NextResponse.json(
{
success: true,
message: 'Permission template created successfully',
template_id: templateId
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create permission template' }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
import { getPermissionCatalog } from '@/features/permission-management/server/permission-template-data';
export async function GET() {
try {
await requireSuperAdminOrganizationAccess();
return NextResponse.json(getPermissionCatalog());
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load permission catalog' }, { status: 500 });
}
}

View File

@@ -0,0 +1,128 @@
import { and, eq, type InferSelectModel } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { products } from '@/db/schema';
import type { ProductMutationPayload } from '@/features/products/api/types';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
function toProductResponse(product: InferSelectModel<typeof products>) {
return {
id: product.id,
organization_id: product.organizationId,
photo_url: product.photoUrl,
name: product.name,
description: product.description,
created_at: product.createdAt.toISOString(),
price: product.price,
category: product.category,
updated_at: product.updatedAt.toISOString()
};
}
export async function GET(request: NextRequest, { params }: Params) {
try {
const { organization } = await requireOrganizationAccess();
const { id } = await params;
const product = await db.query.products.findFirst({
where: and(eq(products.id, Number(id)), eq(products.organizationId, organization.id))
});
if (!product) {
return NextResponse.json(
{ success: false, message: `Product with ID ${id} not found` },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: `Product with ID ${id} found`,
product: toProductResponse(product)
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load product' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { organization } = await requireOrganizationAccess();
const { id } = await params;
const body = (await request.json()) as ProductMutationPayload;
const existingProduct = await db.query.products.findFirst({
where: and(eq(products.id, Number(id)), eq(products.organizationId, organization.id))
});
if (!existingProduct) {
return NextResponse.json(
{ success: false, message: `Product with ID ${id} not found` },
{ status: 404 }
);
}
const [product] = await db
.update(products)
.set({
name: body.name,
category: body.category,
price: body.price,
description: body.description,
photoUrl: body.photoDataUrl || existingProduct.photoUrl,
updatedAt: new Date()
})
.where(and(eq(products.id, Number(id)), eq(products.organizationId, organization.id)))
.returning();
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Product updated successfully',
product: toProductResponse(product)
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to update product' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { organization } = await requireOrganizationAccess();
const { id } = await params;
const [deletedProduct] = await db
.delete(products)
.where(and(eq(products.id, Number(id)), eq(products.organizationId, organization.id)))
.returning({ id: products.id });
if (!deletedProduct) {
return NextResponse.json(
{ success: false, message: `Product with ID ${id} not found` },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
message: 'Product deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete product' }, { status: 500 });
}
}

View File

@@ -0,0 +1,146 @@
import { and, asc, count, desc, eq, ilike, inArray, or } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { products } from '@/db/schema';
import type { ProductMutationPayload } from '@/features/products/api/types';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
const sortMap = {
name: products.name,
category: products.category,
price: products.price,
created_at: products.createdAt,
updated_at: products.updatedAt
} as const;
function buildProductImageUrl(name: string) {
return `https://placehold.co/120x120/336b4a/ffffff/png?text=${encodeURIComponent(name.slice(0, 12))}`;
}
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess();
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const categories = searchParams.get('categories');
const search = searchParams.get('search');
const sort = searchParams.get('sort');
const categoryValues = categories
? categories
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean)
: [];
const filters = [
eq(products.organizationId, organization.id),
...(categoryValues.length ? [inArray(products.category, categoryValues)] : []),
...(search
? [
or(
ilike(products.name, `%${search}%`),
ilike(products.description, `%${search}%`),
ilike(products.category, `%${search}%`)
)!
]
: [])
];
const where = filters.length === 1 ? filters[0] : and(...filters);
const [totalResult] = await db.select({ value: count() }).from(products).where(where);
let orderByClause = desc(products.createdAt);
if (sort) {
try {
const sortItems = JSON.parse(sort) as Array<{ id: keyof typeof sortMap; desc: boolean }>;
const primarySort = sortItems[0];
if (primarySort?.id && sortMap[primarySort.id]) {
orderByClause = primarySort.desc
? desc(sortMap[primarySort.id])
: asc(sortMap[primarySort.id]);
}
} catch {
orderByClause = desc(products.createdAt);
}
}
const offset = (page - 1) * limit;
const rows = await db.select().from(products).where(where).orderBy(orderByClause).limit(limit).offset(offset);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Products loaded successfully',
total_products: totalResult?.value ?? 0,
offset,
limit,
products: rows.map((product) => ({
id: product.id,
organization_id: product.organizationId,
photo_url: product.photoUrl,
name: product.name,
description: product.description,
created_at: product.createdAt.toISOString(),
price: product.price,
category: product.category,
updated_at: product.updatedAt.toISOString()
}))
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load products' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess();
const body = (await request.json()) as ProductMutationPayload;
const [product] = await db
.insert(products)
.values({
organizationId: organization.id,
name: body.name,
category: body.category,
price: body.price,
description: body.description,
photoUrl: body.photoDataUrl || buildProductImageUrl(body.name)
})
.returning();
return NextResponse.json(
{
success: true,
time: new Date().toISOString(),
message: 'Product created successfully',
product: {
id: product.id,
organization_id: product.organizationId,
photo_url: product.photoUrl,
name: product.name,
description: product.description,
created_at: product.createdAt.toISOString(),
price: product.price,
category: product.category,
updated_at: product.updatedAt.toISOString()
}
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to create product' }, { status: 500 });
}
}

View File

@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthError } from '@/lib/auth/session';
import {
createExcelBuffer,
createPdfBuffer,
getReportsAccess,
getReportsDataset,
parseReportFilters
} from '@/features/reports/server/report-data';
const supportedReports = new Set([
'employeeTranscript',
'trainingMatrix',
'departmentSummary',
'annualTrainingSummary'
] as const);
const supportedFormats = new Set(['xlsx', 'pdf'] as const);
type ExportableReport =
| 'employeeTranscript'
| 'trainingMatrix'
| 'departmentSummary'
| 'annualTrainingSummary';
type ExportFormat = 'xlsx' | 'pdf';
export async function GET(request: NextRequest) {
try {
const report = request.nextUrl.searchParams.get('report');
const format = request.nextUrl.searchParams.get('format');
if (!report || !supportedReports.has(report as ExportableReport)) {
return NextResponse.json({ message: 'Unsupported report type' }, { status: 400 });
}
if (!format || !supportedFormats.has(format as ExportFormat)) {
return NextResponse.json({ message: 'Unsupported export format' }, { status: 400 });
}
const filters = parseReportFilters({
year: request.nextUrl.searchParams.get('year') ?? undefined,
company: request.nextUrl.searchParams.get('company') ?? undefined,
departmentId: request.nextUrl.searchParams.get('departmentId') ?? undefined
});
const access = await getReportsAccess();
if (!access.canView) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
if (!access.canViewAll && report !== 'employeeTranscript') {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const { role: _role, ...dataset } = await getReportsDataset(filters, access);
const today = new Date().toISOString().slice(0, 10);
if (format === 'xlsx') {
const buffer = createExcelBuffer(report as ExportableReport, dataset);
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${report}-${today}.xlsx"`
}
});
}
const buffer = await createPdfBuffer(report as ExportableReport, dataset);
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${report}-${today}.pdf"`
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
console.error('Failed to export report', error);
return NextResponse.json({ message: 'Failed to export report' }, { status: 500 });
}
}

View File

@@ -0,0 +1,165 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { trainingMatrices } from '@/db/schema';
import type { TrainingMatrixMutationPayload } from '@/features/training-matrix/api/types';
import {
canManageTrainingMatrix,
getTrainingMatrixByIdForOrganization,
normalizeTrainingMatrixPayload,
serializeTrainingMatrix
} from '@/features/training-matrix/server/training-matrix-data';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
export async function GET(request: NextRequest, { params }: Params) {
try {
const { organization, membership } = await requireOrganizationAccess();
if (!canManageTrainingMatrix(membership.role)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const matrix = await getTrainingMatrixByIdForOrganization({
id: Number(id),
organizationId: organization.id
});
if (!matrix) {
return NextResponse.json(
{ success: false, message: `Training matrix with ID ${id} not found` },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: `Training matrix with ID ${id} found`,
training_matrix: serializeTrainingMatrix(matrix)
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load training matrix' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { organization, membership } = await requireOrganizationAccess();
if (!canManageTrainingMatrix(membership.role)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = normalizeTrainingMatrixPayload(
(await request.json()) as TrainingMatrixMutationPayload
);
const existingMatrix = await getTrainingMatrixByIdForOrganization({
id: Number(id),
organizationId: organization.id
});
if (!existingMatrix) {
return NextResponse.json(
{ success: false, message: `Training matrix with ID ${id} not found` },
{ status: 404 }
);
}
const [matrix] = await db
.update(trainingMatrices)
.set({
departmentId: body.departmentId,
positionId: body.positionId,
courseId: body.courseId,
isRequired: body.isRequired,
renewalPeriodMonths: body.renewalPeriodMonths,
note: body.note,
updatedAt: new Date()
})
.where(
and(eq(trainingMatrices.id, Number(id)), eq(trainingMatrices.organizationId, organization.id))
)
.returning();
const updatedMatrix = await getTrainingMatrixByIdForOrganization({
id: matrix.id,
organizationId: organization.id
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Training matrix updated successfully',
training_matrix: updatedMatrix
? serializeTrainingMatrix(updatedMatrix)
: serializeTrainingMatrix({
id: matrix.id,
organizationId: matrix.organizationId,
departmentId: matrix.departmentId,
departmentName: existingMatrix.departmentName,
positionId: matrix.positionId,
positionName: existingMatrix.positionName,
courseId: matrix.courseId,
courseName: existingMatrix.courseName,
courseCategory: existingMatrix.courseCategory,
courseCertificateValidityMonths: existingMatrix.courseCertificateValidityMonths,
isRequired: matrix.isRequired,
renewalPeriodMonths: matrix.renewalPeriodMonths,
note: matrix.note,
createdAt: matrix.createdAt,
updatedAt: matrix.updatedAt
})
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to update training matrix' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { organization, membership } = await requireOrganizationAccess();
if (!canManageTrainingMatrix(membership.role)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const [deletedMatrix] = await db
.delete(trainingMatrices)
.where(
and(eq(trainingMatrices.id, Number(id)), eq(trainingMatrices.organizationId, organization.id))
)
.returning({ id: trainingMatrices.id });
if (!deletedMatrix) {
return NextResponse.json(
{ success: false, message: `Training matrix with ID ${id} not found` },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
message: 'Training matrix deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete training matrix' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import {
canManageTrainingMatrix,
getTrainingMatrixComplianceForOrganization
} from '@/features/training-matrix/server/training-matrix-data';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET() {
try {
const { organization, membership } = await requireOrganizationAccess();
if (!canManageTrainingMatrix(membership.role)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const employees = await getTrainingMatrixComplianceForOrganization(organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Training compliance loaded successfully',
employees
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load training compliance' }, { status: 500 });
}
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import {
canManageTrainingMatrix,
getTrainingMatrixOptions
} from '@/features/training-matrix/server/training-matrix-data';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET() {
try {
const { organization, membership } = await requireOrganizationAccess();
if (!canManageTrainingMatrix(membership.role)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const options = await getTrainingMatrixOptions(organization.id);
return NextResponse.json(options);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load training matrix options' }, { status: 500 });
}
}

View File

@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server';
import { trainingMatrices } from '@/db/schema';
import type { TrainingMatrixMutationPayload } from '@/features/training-matrix/api/types';
import {
canManageTrainingMatrix,
listTrainingMatricesForOrganization,
normalizeTrainingMatrixPayload,
serializeTrainingMatrix
} from '@/features/training-matrix/server/training-matrix-data';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const { organization, membership } = await requireOrganizationAccess();
if (!canManageTrainingMatrix(membership.role)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const { searchParams } = request.nextUrl;
const result = await listTrainingMatricesForOrganization({
organizationId: organization.id,
filters: {
page: Number(searchParams.get('page') ?? 1),
limit: Number(searchParams.get('limit') ?? 10),
search: searchParams.get('search') ?? undefined,
sort: searchParams.get('sort') ?? undefined
}
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Training matrix loaded successfully',
total_training_matrices: result.total,
offset: result.offset,
limit: result.limit,
training_matrices: result.matrices
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load training matrix' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, membership } = await requireOrganizationAccess();
if (!canManageTrainingMatrix(membership.role)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const body = normalizeTrainingMatrixPayload(
(await request.json()) as TrainingMatrixMutationPayload
);
const [matrix] = await db
.insert(trainingMatrices)
.values({
organizationId: organization.id,
departmentId: body.departmentId,
positionId: body.positionId,
courseId: body.courseId,
isRequired: body.isRequired,
renewalPeriodMonths: body.renewalPeriodMonths,
note: body.note
})
.returning();
const fullMatrix = await listTrainingMatricesForOrganization({
organizationId: organization.id,
filters: { page: 1, limit: 1, search: String(matrix.id) }
});
return NextResponse.json(
{
success: true,
time: new Date().toISOString(),
message: 'Training matrix created successfully',
training_matrix:
fullMatrix.matrices.find((item) => item.id === matrix.id) ??
serializeTrainingMatrix({
id: matrix.id,
organizationId: matrix.organizationId,
departmentId: matrix.departmentId,
departmentName: null,
positionId: matrix.positionId,
positionName: null,
courseId: matrix.courseId,
courseName: '',
courseCategory: '',
courseCertificateValidityMonths: null,
isRequired: matrix.isRequired,
renewalPeriodMonths: matrix.renewalPeriodMonths,
note: matrix.note,
createdAt: matrix.createdAt,
updatedAt: matrix.updatedAt
})
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to create training matrix' }, { status: 500 });
}
}

View File

@@ -0,0 +1,95 @@
import { and, eq, ne } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { trainingPolicies } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
getTrainingPolicyByIdForOrganization,
serializeTrainingPolicy
} from '@/features/training-policy/server/training-policy-data';
import { AuthError, requirePermission } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { organization, session } = await requirePermission('training_policy:write');
const requestMetadata = getRequestMetadata(request);
const { id } = await params;
const policyId = Number(id);
const policy = await getTrainingPolicyByIdForOrganization({
id: policyId,
organizationId: organization.id
});
if (!policy) {
return NextResponse.json(
{ success: false, message: `ไม่พบนโยบายการอบรมรหัส ${id}` },
{ status: 404 }
);
}
await db.transaction(async (tx) => {
await tx
.update(trainingPolicies)
.set({
isActive: false,
updatedAt: new Date()
})
.where(
and(
eq(trainingPolicies.organizationId, organization.id),
ne(trainingPolicies.id, policyId)
)
);
await tx
.update(trainingPolicies)
.set({
isActive: true,
updatedAt: new Date()
})
.where(and(eq(trainingPolicies.id, policyId), eq(trainingPolicies.organizationId, organization.id)));
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.TRAINING_POLICY,
action: AUDIT_ACTION.ACTIVATE,
entityName: `ปี ${policy.year}`,
entityId: policy.id,
oldValue: toAuditValue(serializeTrainingPolicy(policy)),
newValue: toAuditValue(
serializeTrainingPolicy(
{
...policy,
isActive: true,
updatedAt: new Date()
},
{ updatedByName: session.user.name ?? null }
)
),
...requestMetadata
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'เปิดใช้งานนโยบายการอบรมเรียบร้อยแล้ว'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
console.error('POST /api/training-policies/[id]/activate failed', error);
return NextResponse.json({ message: 'ไม่สามารถเปิดใช้งานนโยบายการอบรมได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { trainingPolicies } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
getNextTrainingPolicyYearForClone,
getTrainingPolicyByIdForOrganization,
serializeTrainingPolicy
} from '@/features/training-policy/server/training-policy-data';
import { AuthError, requirePermission } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { organization, session } = await requirePermission('training_policy:write');
const requestMetadata = getRequestMetadata(request);
const { id } = await params;
const policyId = Number(id);
const sourcePolicy = await getTrainingPolicyByIdForOrganization({
id: policyId,
organizationId: organization.id
});
if (!sourcePolicy) {
return NextResponse.json(
{ success: false, message: `ไม่พบนโยบายการอบรมรหัส ${id}` },
{ status: 404 }
);
}
const nextYear = await getNextTrainingPolicyYearForClone({
organizationId: organization.id,
baseYear: sourcePolicy.year
});
const [clonedPolicy] = await db
.insert(trainingPolicies)
.values({
organizationId: organization.id,
year: nextYear,
totalHours: sourcePolicy.totalHours,
kHours: sourcePolicy.kHours,
sHours: sourcePolicy.sHours,
aHours: sourcePolicy.aHours,
isActive: false
})
.returning();
const serializedPolicy = serializeTrainingPolicy(clonedPolicy, {
updatedByName: session.user.name ?? null
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.TRAINING_POLICY,
action: AUDIT_ACTION.CLONE,
entityName: `ปี ${clonedPolicy.year}`,
entityId: clonedPolicy.id,
oldValue: toAuditValue(serializeTrainingPolicy(sourcePolicy)),
newValue: toAuditValue(serializedPolicy),
...requestMetadata
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'โคลนนโยบายการอบรมเรียบร้อยแล้ว',
policy_id: clonedPolicy.id
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
console.error('POST /api/training-policies/[id]/clone failed', error);
return NextResponse.json({ message: 'ไม่สามารถโคลนนโยบายการอบรมได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,80 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { trainingPolicies } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
getTrainingPolicyByIdForOrganization,
serializeTrainingPolicy
} from '@/features/training-policy/server/training-policy-data';
import { AuthError, requirePermission } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { organization, session } = await requirePermission('training_policy:write');
const requestMetadata = getRequestMetadata(request);
const { id } = await params;
const policyId = Number(id);
const policy = await getTrainingPolicyByIdForOrganization({
id: policyId,
organizationId: organization.id
});
if (!policy) {
return NextResponse.json(
{ success: false, message: `ไม่พบนโยบายการอบรมรหัส ${id}` },
{ status: 404 }
);
}
await db
.update(trainingPolicies)
.set({
isActive: false,
updatedAt: new Date()
})
.where(and(eq(trainingPolicies.id, policyId), eq(trainingPolicies.organizationId, organization.id)));
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.TRAINING_POLICY,
action: AUDIT_ACTION.DEACTIVATE,
entityName: `ปี ${policy.year}`,
entityId: policy.id,
oldValue: toAuditValue(serializeTrainingPolicy(policy)),
newValue: toAuditValue(
serializeTrainingPolicy(
{
...policy,
isActive: false,
updatedAt: new Date()
},
{ updatedByName: session.user.name ?? null }
)
),
...requestMetadata
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'ปิดใช้งานนโยบายการอบรมเรียบร้อยแล้ว'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
console.error('POST /api/training-policies/[id]/deactivate failed', error);
return NextResponse.json({ message: 'ไม่สามารถปิดใช้งานนโยบายการอบรมได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { listTrainingPolicyHistoryForOrganization } from '@/features/training-policy/server/training-policy-data';
import { AuthError, requirePermission } from '@/lib/auth/session';
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { organization } = await requirePermission('training_policy:read');
const { id } = await params;
const policyId = Number(id);
const history = await listTrainingPolicyHistoryForOrganization({
organizationId: organization.id,
policyId
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'โหลดประวัตินโยบายการอบรมเรียบร้อยแล้ว',
history
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
console.error('GET /api/training-policies/[id]/history failed', error);
return NextResponse.json({ message: 'ไม่สามารถโหลดประวัตินโยบายการอบรมได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,155 @@
import { and, eq, ne } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { trainingPolicies } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import type { TrainingPolicyMutationPayload } from '@/features/training-policy/api/types';
import {
getTrainingPolicyByIdForOrganization,
getTrainingPolicyByYearForOrganization,
getTrainingPolicyDetailForOrganization,
normalizeTrainingPolicyPayload,
serializeTrainingPolicy
} from '@/features/training-policy/server/training-policy-data';
import { AuthError, requirePermission } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { organization } = await requirePermission('training_policy:read');
const { id } = await params;
const policyId = Number(id);
const policy = await getTrainingPolicyDetailForOrganization({
id: policyId,
organizationId: organization.id
});
if (!policy) {
return NextResponse.json(
{ success: false, message: `ไม่พบนโยบายการอบรมรหัส ${id}` },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'โหลดรายละเอียดนโยบายการอบรมเรียบร้อยแล้ว',
policy
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
console.error('GET /api/training-policies/[id] failed', error);
return NextResponse.json({ message: 'ไม่สามารถโหลดนโยบายการอบรมได้' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { organization, session } = await requirePermission('training_policy:write');
const requestMetadata = getRequestMetadata(request);
const { id } = await params;
const policyId = Number(id);
const body = normalizeTrainingPolicyPayload(
(await request.json()) as TrainingPolicyMutationPayload
);
const existingPolicy = await getTrainingPolicyByIdForOrganization({
id: policyId,
organizationId: organization.id
});
if (!existingPolicy) {
return NextResponse.json(
{ success: false, message: `ไม่พบนโยบายการอบรมรหัส ${id}` },
{ status: 404 }
);
}
const duplicatePolicy = await getTrainingPolicyByYearForOrganization({
year: body.year,
organizationId: organization.id,
excludeId: policyId
});
if (duplicatePolicy) {
return NextResponse.json(
{
success: false,
message: `มีนโยบายการอบรมสำหรับปี ${body.year} อยู่แล้ว`
},
{ status: 409 }
);
}
const policy = await db.transaction(async (tx) => {
if (body.isActive) {
await tx
.update(trainingPolicies)
.set({ isActive: false, updatedAt: new Date() })
.where(
and(
eq(trainingPolicies.organizationId, organization.id),
ne(trainingPolicies.id, policyId)
)
);
}
const [updatedPolicy] = await tx
.update(trainingPolicies)
.set({
year: body.year,
totalHours: body.totalHours,
kHours: body.kHours,
sHours: body.sHours,
aHours: body.aHours,
isActive: body.isActive,
updatedAt: new Date()
})
.where(and(eq(trainingPolicies.id, policyId), eq(trainingPolicies.organizationId, organization.id)))
.returning();
return updatedPolicy;
});
const serializedPolicy = serializeTrainingPolicy(policy, {
updatedByName: session.user.name ?? null
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.TRAINING_POLICY,
action: AUDIT_ACTION.UPDATE,
entityName: `ปี ${policy.year}`,
entityId: policy.id,
oldValue: toAuditValue(serializeTrainingPolicy(existingPolicy)),
newValue: toAuditValue(serializedPolicy),
...requestMetadata
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'อัปเดตนโยบายการอบรมเรียบร้อยแล้ว',
policy: serializedPolicy
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
console.error('PATCH /api/training-policies/[id] failed', error);
return NextResponse.json({ message: 'ไม่สามารถอัปเดตนโยบายการอบรมได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,144 @@
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { trainingPolicies } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import type {
TrainingPolicyFilters,
TrainingPolicyMutationPayload
} from '@/features/training-policy/api/types';
import {
getTrainingPolicyByYearForOrganization,
listTrainingPoliciesForOrganization,
normalizeTrainingPolicyPayload,
serializeTrainingPolicy
} from '@/features/training-policy/server/training-policy-data';
import { AuthError, requirePermission } from '@/lib/auth/session';
import { db } from '@/lib/db';
function readFilters(request: NextRequest): TrainingPolicyFilters {
const { searchParams } = request.nextUrl;
return {
page: Number(searchParams.get('page') ?? 1),
limit: Number(searchParams.get('limit') ?? 10),
search: searchParams.get('search') ?? undefined,
status: searchParams.get('status') ?? undefined,
sort: searchParams.get('sort') ?? undefined
};
}
export async function GET(request: NextRequest) {
try {
const { organization } = await requirePermission('training_policy:read');
const filters = readFilters(request);
const result = await listTrainingPoliciesForOrganization({
organizationId: organization.id,
filters
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'โหลดนโยบายการอบรมเรียบร้อยแล้ว',
total_training_policies: result.total,
offset: result.offset,
limit: result.limit,
active_policy: result.activePolicy,
policies: result.policies
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
console.error('GET /api/training-policies failed', error);
return NextResponse.json({ message: 'ไม่สามารถโหลดนโยบายการอบรมได้' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requirePermission('training_policy:write');
const requestMetadata = getRequestMetadata(request);
const body = normalizeTrainingPolicyPayload(
(await request.json()) as TrainingPolicyMutationPayload
);
const existingPolicy = await getTrainingPolicyByYearForOrganization({
year: body.year,
organizationId: organization.id
});
if (existingPolicy) {
return NextResponse.json(
{
success: false,
message: `มีนโยบายการอบรมสำหรับปี ${body.year} อยู่แล้ว`
},
{ status: 409 }
);
}
const policy = await db.transaction(async (tx) => {
if (body.isActive) {
await tx
.update(trainingPolicies)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(trainingPolicies.organizationId, organization.id));
}
const [createdPolicy] = await tx
.insert(trainingPolicies)
.values({
organizationId: organization.id,
year: body.year,
totalHours: body.totalHours,
kHours: body.kHours,
sHours: body.sHours,
aHours: body.aHours,
isActive: body.isActive
})
.returning();
return createdPolicy;
});
const serializedPolicy = serializeTrainingPolicy(policy, {
updatedByName: session.user.name ?? null
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.TRAINING_POLICY,
action: AUDIT_ACTION.CREATE,
entityName: `ปี ${policy.year}`,
entityId: policy.id,
newValue: toAuditValue(serializedPolicy),
...requestMetadata
});
return NextResponse.json(
{
success: true,
time: new Date().toISOString(),
message: 'สร้างนโยบายการอบรมเรียบร้อยแล้ว',
policy: serializedPolicy
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
console.error('POST /api/training-policies failed', error);
return NextResponse.json({ message: 'ไม่สามารถสร้างนโยบายการอบรมได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,72 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { certificates } from '@/db/schema';
import { getTrainingRecordByIdForOrganization } from '@/features/training-records/server/training-record-data';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
import { readStoredCertificate } from '@/lib/certificate-storage';
export const runtime = 'nodejs';
type Params = { params: Promise<{ id: string; certificateId: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { organization, session, membership } = await requireOrganizationAccess();
const { id, certificateId } = await params;
const trainingRecordId = Number(id);
const parsedCertificateId = Number(certificateId);
if (Number.isNaN(trainingRecordId) || Number.isNaN(parsedCertificateId)) {
return NextResponse.json({ message: 'Invalid certificate request' }, { status: 400 });
}
const record = await getTrainingRecordByIdForOrganization({
id: trainingRecordId,
organizationId: organization.id,
userId: session.user.id,
role: membership.role
});
if (!record) {
return NextResponse.json({ message: 'ไม่พบรายการอบรม' }, { status: 404 });
}
const certificate = await db.query.certificates.findFirst({
where: and(
eq(certificates.id, parsedCertificateId),
eq(certificates.trainingRecordId, trainingRecordId),
eq(certificates.organizationId, organization.id)
)
});
if (!certificate) {
return NextResponse.json({ message: 'ไม่พบเอกสารแนบ' }, { status: 404 });
}
const file = await readStoredCertificate(certificate.storageKey);
const disposition =
certificate.fileType === 'application/pdf' || certificate.fileType.startsWith('image/')
? 'inline'
: 'attachment';
return new NextResponse(file, {
headers: {
'Content-Type': certificate.fileType,
'Content-Length': String(certificate.fileSize),
'Content-Disposition': `${disposition}; filename*=UTF-8''${encodeURIComponent(certificate.fileName)}`,
'Cache-Control': 'private, no-store'
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return NextResponse.json({ message: 'ไม่พบไฟล์เอกสารแนบ' }, { status: 404 });
}
return NextResponse.json({ message: 'ไม่สามารถเปิดเอกสารแนบได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,100 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { certificates } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
canEmployeeManageEditableRecord,
getTrainingRecordByIdForOrganization
} from '@/features/training-records/server/training-record-data';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
import { deleteStoredCertificate } from '@/lib/certificate-storage';
export const runtime = 'nodejs';
type Params = { params: Promise<{ id: string; certificateId: string }> };
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { organization, session, membership } = await requireOrganizationAccess();
const requestMetadata = getRequestMetadata(request);
const { id, certificateId } = await params;
const record = await getTrainingRecordByIdForOrganization({
id: Number(id),
organizationId: organization.id,
userId: session.user.id,
role: membership.role
});
if (!record) {
return NextResponse.json({ message: 'ไม่พบรายการอบรม' }, { status: 404 });
}
if (
!(await canEmployeeManageEditableRecord({
organizationId: organization.id,
role: membership.role,
recordUserId: record.userId,
recordEmployeeId: record.employeeId,
actorUserId: session.user.id,
approvalStatus: record.approvalStatus
}))
) {
return NextResponse.json(
{ message: 'พนักงานสามารถลบเอกสารได้เฉพาะรายการที่อยู่ในสถานะฉบับร่างหรือขอแก้ไขเท่านั้น' },
{ status: 403 }
);
}
const [certificate] = await db
.delete(certificates)
.where(
and(
eq(certificates.id, Number(certificateId)),
eq(certificates.trainingRecordId, Number(id)),
eq(certificates.organizationId, organization.id)
)
)
.returning();
if (!certificate) {
return NextResponse.json({ message: 'ไม่พบเอกสารแนบ' }, { status: 404 });
}
await deleteStoredCertificate(certificate.storageKey);
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.TRAINING_RECORDS,
action: AUDIT_ACTION.DELETE,
entityName: certificate.fileName,
entityId: certificate.id,
oldValue: toAuditValue({
id: certificate.id,
training_record_id: certificate.trainingRecordId,
file_name: certificate.fileName,
file_type: certificate.fileType,
file_size: certificate.fileSize
}),
...requestMetadata
});
return NextResponse.json({
success: true,
message: 'ลบเอกสารแนบเรียบร้อยแล้ว'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถลบเอกสารแนบได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from 'next/server';
import { certificates } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
canEmployeeManageEditableRecord,
getTrainingRecordByIdForOrganization
} from '@/features/training-records/server/training-record-data';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
import { saveCertificateFile } from '@/lib/certificate-storage';
export const runtime = 'nodejs';
type Params = { params: Promise<{ id: string }> };
function serializeCertificate(certificate: typeof certificates.$inferSelect) {
return {
id: certificate.id,
organization_id: certificate.organizationId,
training_record_id: certificate.trainingRecordId,
file_name: certificate.fileName,
file_type: certificate.fileType,
file_size: certificate.fileSize,
storage_key: certificate.storageKey,
file_url: `/api/training-records/${certificate.trainingRecordId}/certificates/${certificate.id}/download`,
uploaded_by: certificate.uploadedBy,
uploaded_at: certificate.uploadedAt.toISOString(),
created_at: certificate.createdAt.toISOString(),
updated_at: certificate.updatedAt.toISOString()
};
}
export async function GET(request: NextRequest, { params }: Params) {
try {
const { organization, session, membership } = await requireOrganizationAccess();
const { id } = await params;
const record = await getTrainingRecordByIdForOrganization({
id: Number(id),
organizationId: organization.id,
userId: session.user.id,
role: membership.role
});
if (!record) {
return NextResponse.json({ message: 'ไม่พบรายการอบรม' }, { status: 404 });
}
const rows = await db.query.certificates.findMany({
where: (table, { and, eq }) =>
and(eq(table.trainingRecordId, Number(id)), eq(table.organizationId, organization.id)),
orderBy: (table, { desc }) => [desc(table.uploadedAt)]
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'โหลดเอกสารแนบเรียบร้อยแล้ว',
certificates: rows.map(serializeCertificate)
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถโหลดเอกสารแนบได้' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { organization, session, membership } = await requireOrganizationAccess();
const requestMetadata = getRequestMetadata(request);
const { id } = await params;
const record = await getTrainingRecordByIdForOrganization({
id: Number(id),
organizationId: organization.id,
userId: session.user.id,
role: membership.role
});
if (!record) {
return NextResponse.json({ message: 'ไม่พบรายการอบรม' }, { status: 404 });
}
if (
!(await canEmployeeManageEditableRecord({
organizationId: organization.id,
role: membership.role,
recordUserId: record.userId,
recordEmployeeId: record.employeeId,
actorUserId: session.user.id,
approvalStatus: record.approvalStatus
}))
) {
return NextResponse.json(
{ message: 'พนักงานสามารถอัปโหลดเอกสารได้เฉพาะรายการที่อยู่ในสถานะฉบับร่างหรือขอแก้ไขเท่านั้น' },
{ status: 403 }
);
}
const formData = await request.formData();
const files = formData.getAll('files').filter((entry): entry is File => entry instanceof File);
if (!files.length) {
return NextResponse.json({ message: 'กรุณาเลือกไฟล์อย่างน้อย 1 ไฟล์' }, { status: 400 });
}
const uploadedCertificates = [];
for (const file of files) {
const storedFile = await saveCertificateFile({
file,
organizationId: organization.id,
trainingRecordId: Number(id)
});
const [certificate] = await db
.insert(certificates)
.values({
organizationId: organization.id,
trainingRecordId: Number(id),
fileName: storedFile.fileName,
fileType: storedFile.fileType,
fileSize: storedFile.fileSize,
storageKey: storedFile.storageKey,
fileUrl: storedFile.fileUrl,
uploadedBy: session.user.id
})
.returning();
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.TRAINING_RECORDS,
action: AUDIT_ACTION.CREATE,
entityName: certificate.fileName,
entityId: certificate.id,
newValue: toAuditValue(serializeCertificate(certificate)),
...requestMetadata
});
uploadedCertificates.push(serializeCertificate(certificate));
}
return NextResponse.json(
{
success: true,
time: new Date().toISOString(),
message: 'อัปโหลดเอกสารแนบเรียบร้อยแล้ว',
certificates: uploadedCertificates
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json(
{
message: error instanceof Error ? error.message : 'ไม่สามารถอัปโหลดเอกสารแนบได้'
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,213 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { trainingRecords, users } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
createTrainingApprovedNotification,
createTrainingRejectedNotification
} from '@/features/notifications/server/notification-service';
import type { TrainingRecordReviewPayload } from '@/features/training-records/api/types';
import {
canReviewTrainingRecords,
getTrainingRecordByIdForOrganization,
isMissingTrainingRecordMinutesStorage,
serializeTrainingRecord
} from '@/features/training-records/server/training-record-data';
import { trainingRecordReviewSchema } from '@/features/training-records/schemas/training-record';
import { minutesToDecimalHours } from '@/features/training-records/utils/time-utils';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { organization, session, membership } = await requireOrganizationAccess();
const requestMetadata = getRequestMetadata(request);
if (!canReviewTrainingRecords(membership.role)) {
return NextResponse.json({ message: 'คุณไม่มีสิทธิ์ดำเนินการนี้' }, { status: 403 });
}
const { id } = await params;
const parsedBody = trainingRecordReviewSchema.safeParse(
(await request.json()) as TrainingRecordReviewPayload
);
if (!parsedBody.success) {
const issue = parsedBody.error.issues[0];
return NextResponse.json(
{ message: issue?.message ?? 'ข้อมูลการตรวจสอบไม่ถูกต้อง' },
{ status: 400 }
);
}
const body = parsedBody.data;
const existingRecord = await getTrainingRecordByIdForOrganization({
id: Number(id),
organizationId: organization.id,
userId: session.user.id,
role: membership.role
});
if (!existingRecord) {
return NextResponse.json(
{ success: false, message: `ไม่พบรายการอบรมรหัส ${id}` },
{ status: 404 }
);
}
if (existingRecord.approvalStatus !== 'pending') {
return NextResponse.json(
{ message: 'สามารถตรวจสอบได้เฉพาะรายการที่อยู่ในสถานะรอตรวจสอบเท่านั้น' },
{ status: 409 }
);
}
const reviewUpdate =
body.status === 'approved'
? {
approvalStatus: 'approved' as const,
category: body.category,
approvedMinutes: body.approvedMinutes,
approvedHours: minutesToDecimalHours(body.approvedMinutes ?? 0),
reviewerNote: body.reviewerNote?.trim() || null,
rejectReason: null,
reviewedByUserId: session.user.id,
reviewedBy: session.user.id,
reviewedAt: new Date(),
updatedAt: new Date()
}
: {
approvalStatus: 'needs_revision' as const,
category: null,
approvedMinutes: null,
approvedHours: null,
reviewerNote: body.reviewerNote?.trim() || null,
rejectReason: body.rejectReason.trim(),
reviewedByUserId: session.user.id,
reviewedBy: session.user.id,
reviewedAt: new Date(),
updatedAt: new Date()
};
let reviewedRecord;
try {
[reviewedRecord] = await db
.update(trainingRecords)
.set(reviewUpdate)
.where(and(eq(trainingRecords.id, Number(id)), eq(trainingRecords.organizationId, organization.id)))
.returning();
} catch (error) {
if (!isMissingTrainingRecordMinutesStorage(error)) {
throw error;
}
const legacyReviewUpdate =
body.status === 'approved'
? {
approvalStatus: 'approved' as const,
category: body.category,
approvedHours: minutesToDecimalHours(body.approvedMinutes ?? 0),
reviewerNote: body.reviewerNote?.trim() || null,
rejectReason: null,
reviewedByUserId: session.user.id,
reviewedBy: session.user.id,
reviewedAt: new Date(),
updatedAt: new Date()
}
: {
approvalStatus: 'needs_revision' as const,
category: null,
approvedHours: null,
reviewerNote: body.reviewerNote?.trim() || null,
rejectReason: body.rejectReason.trim(),
reviewedByUserId: session.user.id,
reviewedBy: session.user.id,
reviewedAt: new Date(),
updatedAt: new Date()
};
[reviewedRecord] = await db
.update(trainingRecords)
.set(legacyReviewUpdate)
.where(and(eq(trainingRecords.id, Number(id)), eq(trainingRecords.organizationId, organization.id)))
.returning();
}
const [reviewer] = await db
.select({
id: users.id,
name: users.name
})
.from(users)
.where(eq(users.id, session.user.id))
.limit(1);
if (body.status === 'approved' && existingRecord.userId) {
await createTrainingApprovedNotification({
organizationId: organization.id,
userId: existingRecord.userId,
courseName: existingRecord.courseName,
category: reviewedRecord.category,
approvedHours: reviewedRecord.approvedHours,
referenceId: reviewedRecord.id
});
} else if (existingRecord.userId) {
await createTrainingRejectedNotification({
organizationId: organization.id,
userId: existingRecord.userId,
courseName: existingRecord.courseName,
rejectReason: reviewedRecord.rejectReason,
referenceId: reviewedRecord.id
});
}
const serializedRecord = serializeTrainingRecord({
...existingRecord,
approvalStatus: reviewedRecord.approvalStatus,
approvedMinutes: reviewedRecord.approvedMinutes,
approvedHours: reviewedRecord.approvedHours,
category: reviewedRecord.category,
reviewerNote: reviewedRecord.reviewerNote,
rejectReason: reviewedRecord.rejectReason,
reviewedBy: reviewedRecord.reviewedBy,
reviewedAt: reviewedRecord.reviewedAt,
updatedAt: reviewedRecord.updatedAt,
createdByName: existingRecord.createdByName ?? reviewer?.name ?? null
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.HRD_REVIEW,
action: body.status === 'approved' ? AUDIT_ACTION.APPROVE : AUDIT_ACTION.REJECT,
entityName: existingRecord.courseName,
entityId: reviewedRecord.id,
oldValue: toAuditValue(serializeTrainingRecord(existingRecord)),
newValue: toAuditValue(serializedRecord),
...requestMetadata
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'ตรวจสอบรายการอบรมเรียบร้อยแล้ว',
training_record: serializedRecord
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถตรวจสอบรายการอบรมได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,319 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { trainingRecords } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import type { TrainingRecordMutationPayload } from '@/features/training-records/api/types';
import {
assertUserAndCourseAccess,
canEmployeeManageEditableRecord,
canReviewTrainingRecords,
getTrainingRecordByIdForOrganization,
isMissingTrainingRecordMinutesStorage,
normalizeTrainingRecordPayload,
serializeTrainingRecord
} from '@/features/training-records/server/training-record-data';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
import { deleteStoredCertificate } from '@/lib/certificate-storage';
type Params = { params: Promise<{ id: string }> };
export async function GET(request: NextRequest, { params }: Params) {
try {
const { organization, session, membership } = await requireOrganizationAccess();
const { id } = await params;
const record = await getTrainingRecordByIdForOrganization({
id: Number(id),
organizationId: organization.id,
userId: session.user.id,
role: membership.role
});
if (!record) {
return NextResponse.json(
{ success: false, message: `ไม่พบรายการอบรมรหัส ${id}` },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: `โหลดรายการอบรมรหัส ${id} เรียบร้อยแล้ว`,
training_record: serializeTrainingRecord(record)
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถโหลดรายการอบรมได้' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { organization, membership, session } = await requireOrganizationAccess();
const requestMetadata = getRequestMetadata(request);
const { id } = await params;
const body = normalizeTrainingRecordPayload(
(await request.json()) as TrainingRecordMutationPayload
);
const existingRecord = await getTrainingRecordByIdForOrganization({
id: Number(id),
organizationId: organization.id,
userId: session.user.id,
role: membership.role
});
if (!existingRecord) {
return NextResponse.json(
{ success: false, message: `ไม่พบรายการอบรมรหัส ${id}` },
{ status: 404 }
);
}
if (
!(await canEmployeeManageEditableRecord({
organizationId: organization.id,
role: membership.role,
recordUserId: existingRecord.userId,
recordEmployeeId: existingRecord.employeeId,
actorUserId: session.user.id,
approvalStatus: existingRecord.approvalStatus
}))
) {
return NextResponse.json(
{ message: 'พนักงานสามารถแก้ไขได้เฉพาะรายการที่อยู่ในสถานะฉบับร่างหรือขอแก้ไขเท่านั้น' },
{ status: 403 }
);
}
if (
existingRecord.approvalStatus === 'approved' &&
!canReviewTrainingRecords(membership.role)
) {
return NextResponse.json(
{ message: 'ผู้ใช้นี้ไม่สามารถแก้ไขรายการที่อนุมัติแล้วได้' },
{ status: 409 }
);
}
const { user, course } = await assertUserAndCourseAccess({
organizationId: organization.id,
targetEmployeeId: body.employeeId,
actorUserId: session.user.id,
actorRole: membership.role,
courseId: body.courseId,
courseName: body.courseName
});
if (!user) {
return NextResponse.json(
{ message: 'ไม่พบผู้ใช้งานในองค์กรที่กำลังใช้งานอยู่' },
{ status: 400 }
);
}
const approvalStatus = body.submissionMode === 'draft' ? 'draft' : 'pending';
const reviewReset =
body.submissionMode === 'submit'
? {
category: null,
approvedMinutes: null,
approvedHours: null,
reviewerNote: null,
rejectReason: null,
reviewedByUserId: null,
reviewedBy: null,
reviewedAt: null
}
: {};
let record;
try {
[record] = await db
.update(trainingRecords)
.set({
userId: user.userId ?? null,
employeeId: body.employeeId,
courseId: course?.id ?? null,
courseName: course?.name ?? body.courseName,
trainingDate: new Date(body.trainingDate),
trainingType: body.trainingType,
submittedMinutes: body.submittedMinutes,
hours: body.hours,
organizer: body.organizer,
note: body.note,
approvalStatus,
...reviewReset,
updatedAt: new Date()
})
.where(and(eq(trainingRecords.id, Number(id)), eq(trainingRecords.organizationId, organization.id)))
.returning();
} catch (error) {
if (!isMissingTrainingRecordMinutesStorage(error)) {
throw error;
}
[record] = await db
.update(trainingRecords)
.set({
userId: user.userId ?? null,
employeeId: body.employeeId,
courseId: course?.id ?? null,
courseName: course?.name ?? body.courseName,
trainingDate: new Date(body.trainingDate),
trainingType: body.trainingType,
hours: body.hours,
organizer: body.organizer,
note: body.note,
approvalStatus,
...reviewReset,
updatedAt: new Date()
})
.where(and(eq(trainingRecords.id, Number(id)), eq(trainingRecords.organizationId, organization.id)))
.returning();
}
const serializedRecord = serializeTrainingRecord({
id: record.id,
organizationId: record.organizationId,
userId: record.userId,
employeeId: record.employeeId,
employeeCode: user.employeeCode ?? existingRecord.employeeCode ?? null,
userName: user.fullName,
companyName: existingRecord.companyName,
departmentName: existingRecord.departmentName,
courseId: record.courseId,
courseName: record.courseName,
trainingDate: record.trainingDate,
trainingType: record.trainingType,
submittedMinutes: record.submittedMinutes,
hours: record.hours,
approvedMinutes: record.approvedMinutes,
approvedHours: record.approvedHours,
category: record.category,
organizer: record.organizer,
note: record.note,
certificatePreviewUrl: existingRecord.certificatePreviewUrl,
certificatePreviewType: existingRecord.certificatePreviewType,
approvalStatus: record.approvalStatus,
reviewerNote: record.reviewerNote,
rejectReason: record.rejectReason,
reviewedBy: record.reviewedBy,
reviewedAt: record.reviewedAt,
createdBy: record.createdBy,
createdByName: existingRecord.createdByName,
createdAt: record.createdAt,
updatedAt: record.updatedAt
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.TRAINING_RECORDS,
action: AUDIT_ACTION.UPDATE,
entityName: record.courseName,
entityId: record.id,
oldValue: toAuditValue(serializeTrainingRecord(existingRecord)),
newValue: toAuditValue(serializedRecord),
...requestMetadata
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'อัปเดตรายการอบรมเรียบร้อยแล้ว',
training_record: serializedRecord
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถอัปเดตรายการอบรมได้' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { organization, membership, session } = await requireOrganizationAccess();
const requestMetadata = getRequestMetadata(request);
const { id } = await params;
const existingRecord = await getTrainingRecordByIdForOrganization({
id: Number(id),
organizationId: organization.id,
userId: session.user.id,
role: membership.role
});
if (!existingRecord) {
return NextResponse.json(
{ success: false, message: `ไม่พบรายการอบรมรหัส ${id}` },
{ status: 404 }
);
}
if (
!(await canEmployeeManageEditableRecord({
organizationId: organization.id,
role: membership.role,
recordUserId: existingRecord.userId,
recordEmployeeId: existingRecord.employeeId,
actorUserId: session.user.id,
approvalStatus: existingRecord.approvalStatus
}))
) {
return NextResponse.json(
{ message: 'พนักงานสามารถลบได้เฉพาะรายการที่อยู่ในสถานะฉบับร่างหรือขอแก้ไขเท่านั้น' },
{ status: 403 }
);
}
const recordCertificates = await db.query.certificates.findMany({
where: (table, { and, eq }) =>
and(eq(table.trainingRecordId, Number(id)), eq(table.organizationId, organization.id))
});
for (const certificate of recordCertificates) {
await deleteStoredCertificate(certificate.storageKey);
}
await db
.delete(trainingRecords)
.where(and(eq(trainingRecords.id, Number(id)), eq(trainingRecords.organizationId, organization.id)));
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.TRAINING_RECORDS,
action: AUDIT_ACTION.DELETE,
entityName: existingRecord.courseName,
entityId: existingRecord.id,
oldValue: toAuditValue(serializeTrainingRecord(existingRecord)),
...requestMetadata
});
return NextResponse.json({
success: true,
message: 'ลบรายการอบรมเรียบร้อยแล้ว'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถลบรายการอบรมได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,439 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import * as XLSX from 'xlsx';
import { importJobs, userEmployeeMaps } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import {
findEmployeeByCodeForOrganization,
syncUsersForEmployeeCode
} from '@/features/employees/server/employee-identity-linking';
import {
normalizeTrainingTypeForDb,
type LegacyTrainingType
} from '@/features/training-records/constants/training-record-options';
import { isMissingTrainingRecordMinutesStorage } from '@/features/training-records/server/training-record-data';
import { decimalHoursToMinutes } from '@/features/training-records/utils/time-utils';
import { trainingRecords } from '@/db/schema';
import { AuthError, requireAllPermissions } from '@/lib/auth/session';
import { db } from '@/lib/db';
import {
trainingRecordImportCategoryValues,
trainingRecordImportTemplateHeaders,
trainingRecordImportTrainingTypeValues
} from '@/features/training-records/constants/import-training-records';
type TrainingRecordImportError = {
row: number;
employee_code?: string;
message: string;
};
type ParsedImportRow = {
rowNumber: number;
employeeCode: string;
courseName: string;
trainingDate: Date;
trainingType: LegacyTrainingType;
locationType: string | null;
hours: number;
category: 'K' | 'S' | 'A';
provider: string | null;
note: string | null;
};
const HEADERS = {
employeeCode: trainingRecordImportTemplateHeaders[0],
courseName: trainingRecordImportTemplateHeaders[1],
trainingDate: trainingRecordImportTemplateHeaders[2],
trainingType: trainingRecordImportTemplateHeaders[3],
locationType: trainingRecordImportTemplateHeaders[4],
hours: trainingRecordImportTemplateHeaders[5],
category: trainingRecordImportTemplateHeaders[6],
provider: trainingRecordImportTemplateHeaders[7],
note: trainingRecordImportTemplateHeaders[8]
} as const;
const allowedTrainingTypeKeys = new Set(trainingRecordImportTrainingTypeValues);
const allowedCategoryValues = new Set(trainingRecordImportCategoryValues);
function rowError(row: number, message: string, employeeCode?: string): TrainingRecordImportError {
return { row, message, employee_code: employeeCode };
}
function normalizeText(value: unknown) {
return String(value ?? '').trim();
}
function parseOptionalNumber(value: unknown) {
const normalized = normalizeText(value);
if (!normalized) {
return null;
}
const parsed = Number(normalized.replaceAll(',', ''));
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
function parseDateValue(value: unknown) {
if (value instanceof Date && Number.isFinite(value.getTime())) {
return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()));
}
if (typeof value === 'number' && Number.isFinite(value)) {
const parsed = XLSX.SSF.parse_date_code(value);
if (parsed) {
return new Date(Date.UTC(parsed.y, parsed.m - 1, parsed.d));
}
}
const normalized = normalizeText(value);
if (!normalized) {
return null;
}
const parsedByRuntime = new Date(normalized);
if (Number.isFinite(parsedByRuntime.getTime())) {
return new Date(
Date.UTC(
parsedByRuntime.getUTCFullYear(),
parsedByRuntime.getUTCMonth(),
parsedByRuntime.getUTCDate()
)
);
}
return null;
}
function parseTrainingType(value: unknown) {
const normalized = normalizeText(value);
if (!normalized) {
return null;
}
if (!allowedTrainingTypeKeys.has(normalized as (typeof trainingRecordImportTrainingTypeValues)[number])) {
return null;
}
return normalizeTrainingTypeForDb(normalized);
}
function parseCategory(value: unknown) {
const normalized = normalizeText(value).toUpperCase();
if (!normalized) {
return null;
}
if (!allowedCategoryValues.has(normalized as 'K' | 'S' | 'A')) {
return null;
}
return normalized as 'K' | 'S' | 'A';
}
function validateHeaders(rows: Record<string, unknown>[]) {
const availableHeaders = rows[0] ? Object.keys(rows[0]) : [];
return trainingRecordImportTemplateHeaders.every((header) => availableHeaders.includes(header));
}
function buildImportedNote({
locationType,
note
}: {
locationType: string | null;
note: string | null;
}) {
const parts = [
locationType ? `location_type: ${locationType}` : null,
note?.trim() ? note.trim() : null
].filter(Boolean);
return parts.length ? parts.join('\n') : null;
}
async function insertApprovedTrainingRecord({
organizationId,
employeeId,
userId,
sessionUserId,
row
}: {
organizationId: string;
employeeId: number;
userId: string | null;
sessionUserId: string;
row: ParsedImportRow;
}) {
const minutes = decimalHoursToMinutes(row.hours);
const baseValues = {
organizationId,
userId,
employeeId,
courseId: null,
courseName: row.courseName,
trainingDate: row.trainingDate,
trainingType: row.trainingType,
hours: row.hours,
approvedHours: row.hours,
category: row.category,
organizer: row.provider,
note: buildImportedNote({
locationType: row.locationType,
note: row.note
}),
approvalStatus: 'approved' as const,
submittedByUserId: sessionUserId,
reviewedByUserId: sessionUserId,
reviewedBy: sessionUserId,
reviewedAt: new Date(),
createdBy: sessionUserId
};
try {
await db.insert(trainingRecords).values({
...baseValues,
submittedMinutes: minutes,
approvedMinutes: minutes
});
} catch (error) {
if (!isMissingTrainingRecordMinutesStorage(error)) {
throw error;
}
await db.insert(trainingRecords).values(baseValues);
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireAllPermissions([
'training_record:write_all',
'training_record:approve'
]);
const requestMetadata = getRequestMetadata(request);
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ message: 'กรุณาแนบไฟล์ Excel สำหรับนำเข้า' }, { status: 400 });
}
if (!file.name.toLowerCase().endsWith('.xlsx')) {
return NextResponse.json({ message: 'ระบบรองรับเฉพาะไฟล์ .xlsx เท่านั้น' }, { status: 400 });
}
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: 'array' });
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
if (!worksheet) {
return NextResponse.json({ message: 'ไม่พบข้อมูลในไฟล์ที่อัปโหลด' }, { status: 400 });
}
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(worksheet, { defval: '' });
if (rows.length === 0) {
return NextResponse.json({ message: 'ไม่พบข้อมูลประวัติการอบรมในไฟล์ที่อัปโหลด' }, { status: 400 });
}
if (!validateHeaders(rows)) {
return NextResponse.json(
{
message: `หัวตารางไม่ถูกต้อง กรุณาใช้ Template นี้: ${trainingRecordImportTemplateHeaders.join(', ')}`
},
{ status: 400 }
);
}
const errors: TrainingRecordImportError[] = [];
const validRows: ParsedImportRow[] = [];
for (const [index, row] of rows.entries()) {
const rowNumber = index + 2;
const employeeCode = normalizeText(row[HEADERS.employeeCode]).toUpperCase();
const courseName = normalizeText(row[HEADERS.courseName]);
const hours = parseOptionalNumber(row[HEADERS.hours]);
const category = parseCategory(row[HEADERS.category]);
const trainingType = parseTrainingType(row[HEADERS.trainingType]);
const trainingDate = parseDateValue(row[HEADERS.trainingDate]);
const locationType = normalizeText(row[HEADERS.locationType]) || null;
const provider = normalizeText(row[HEADERS.provider]) || null;
const note = normalizeText(row[HEADERS.note]) || null;
if (!employeeCode) {
errors.push(rowError(rowNumber, 'กรุณาระบุ employee_code'));
continue;
}
if (!courseName) {
errors.push(rowError(rowNumber, 'course_name ต้องไม่ว่าง', employeeCode));
continue;
}
if (!trainingDate) {
errors.push(rowError(rowNumber, 'training_date ต้องเป็นวันที่ที่ถูกต้อง', employeeCode));
continue;
}
if (hours === null) {
errors.push(rowError(rowNumber, 'กรุณาระบุ hours', employeeCode));
continue;
}
if (Number.isNaN(hours) || hours <= 0) {
errors.push(rowError(rowNumber, 'hours ต้องมากกว่า 0', employeeCode));
continue;
}
if (!category) {
errors.push(rowError(rowNumber, 'category ต้องเป็น K, S หรือ A', employeeCode));
continue;
}
if (!trainingType) {
errors.push(
rowError(
rowNumber,
'training_type ต้องเป็น INTERNAL_TRAINING, EXTERNAL_TRAINING, ONLINE_COURSE หรือ OJT',
employeeCode
)
);
continue;
}
validRows.push({
rowNumber,
employeeCode,
courseName,
trainingDate,
trainingType,
locationType,
hours: Number(hours.toFixed(2)),
category,
provider,
note
});
}
let importedRecords = 0;
for (const row of validRows) {
try {
const employee = await findEmployeeByCodeForOrganization({
organizationId: organization.id,
employeeCode: row.employeeCode
});
if (!employee) {
errors.push(rowError(row.rowNumber, 'employee_code ต้องมีอยู่ในระบบ', row.employeeCode));
continue;
}
const syncedUserIds = await syncUsersForEmployeeCode({
organizationId: organization.id,
employeeCode: row.employeeCode,
employeeId: employee.id
});
let trainingRecordUserId = syncedUserIds[0] ?? null;
if (!trainingRecordUserId) {
const [mappedUser] = await db
.select({ userId: userEmployeeMaps.userId })
.from(userEmployeeMaps)
.where(
and(
eq(userEmployeeMaps.organizationId, organization.id),
eq(userEmployeeMaps.employeeId, employee.id)
)
)
.limit(1);
trainingRecordUserId = mappedUser?.userId ?? null;
}
await insertApprovedTrainingRecord({
organizationId: organization.id,
employeeId: employee.id,
userId: trainingRecordUserId,
sessionUserId: session.user.id,
row
});
importedRecords += 1;
} catch (error) {
errors.push(
rowError(
row.rowNumber,
error instanceof Error
? error.message
: 'เกิดข้อผิดพลาดระหว่างบันทึกประวัติการอบรม',
row.employeeCode
)
);
}
}
const summary = {
total_rows: rows.length,
imported_records: importedRecords,
failed_rows: errors.length
};
const [job] = await db
.insert(importJobs)
.values({
organizationId: organization.id,
importType: 'training_record_excel',
fileName: file.name,
totalRows: rows.length,
successRows: importedRecords,
failedRows: errors.length,
status: 'completed',
createdBy: session.user.id
})
.returning({ id: importJobs.id });
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.TRAINING_RECORDS,
action: AUDIT_ACTION.IMPORT,
entityName: file.name,
entityId: job.id,
newValue: toAuditValue({
file_name: file.name,
summary
}),
...requestMetadata
});
return NextResponse.json({
success: true,
message:
errors.length === 0
? 'นำเข้าประวัติการอบรมเรียบร้อยแล้ว'
: 'นำเข้าประวัติการอบรมสำเร็จบางส่วน กรุณาตรวจสอบรายการที่ผิดพลาด',
import_job_id: job.id,
summary,
errors
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถนำเข้าประวัติการอบรมได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
import * as XLSX from 'xlsx';
import { AuthError, requireAllPermissions } from '@/lib/auth/session';
import { trainingRecordImportTemplateHeaders } from '@/features/training-records/constants/import-training-records';
export async function GET() {
try {
await requireAllPermissions(['training_record:write_all', 'training_record:approve']);
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.aoa_to_sheet([
[...trainingRecordImportTemplateHeaders],
[
'EMP-001',
'React Basic',
'2026-01-15',
'INTERNAL_TRAINING',
'onsite',
6,
'K',
'HRD Academy',
'imported from legacy record'
],
[
'EMP-002',
'Next.js',
'2026-02-20',
'ONLINE_COURSE',
'online',
8,
'S',
'Open Learning',
''
]
]);
worksheet['!cols'] = trainingRecordImportTemplateHeaders.map(() => ({ wch: 20 }));
XLSX.utils.book_append_sheet(workbook, worksheet, 'TrainingRecords');
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': 'attachment; filename="training-record-import-template.xlsx"'
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถสร้าง Template นำเข้าได้' }, { status: 500 });
}
}

View File

@@ -0,0 +1,208 @@
import { NextRequest, NextResponse } from 'next/server';
import { trainingRecords } from '@/db/schema';
import {
AUDIT_ACTION,
AUDIT_MODULE,
getRequestMetadata,
logAuditEvent,
toAuditValue
} from '@/features/audit-logs/server/audit-service';
import type { TrainingRecordMutationPayload } from '@/features/training-records/api/types';
import {
assertUserAndCourseAccess,
isMissingTrainingRecordMinutesStorage,
listTrainingRecordsForOrganization,
normalizeTrainingRecordPayload,
serializeTrainingRecord
} from '@/features/training-records/server/training-record-data';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { getDatabaseErrorMessage } from '@/lib/db-errors';
import { db } from '@/lib/db';
function getFilterParam(searchParams: URLSearchParams, ...keys: string[]) {
for (const key of keys) {
const value = searchParams.get(key)?.trim();
if (value && value !== 'all') {
return value;
}
}
return undefined;
}
export async function GET(request: NextRequest) {
try {
const { organization, session, membership } = await requireOrganizationAccess();
const { searchParams } = request.nextUrl;
const result = await listTrainingRecordsForOrganization({
organizationId: organization.id,
userId: session.user.id,
role: membership.role,
filters: {
page: Number(searchParams.get('page') ?? 1),
limit: Number(searchParams.get('limit') ?? 10),
search: getFilterParam(searchParams, 'search', 'user_name', 'name'),
statuses: getFilterParam(searchParams, 'statuses', 'approval_status', 'status'),
types: getFilterParam(searchParams, 'types', 'training_type', 'type'),
year: getFilterParam(searchParams, 'year'),
company: getFilterParam(searchParams, 'company'),
department: getFilterParam(searchParams, 'department'),
sort: searchParams.get('sort') ?? undefined
}
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'โหลดประวัติการอบรมเรียบร้อยแล้ว',
total_training_records: result.total,
offset: result.offset,
limit: result.limit,
training_records: result.trainingRecords
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'ไม่สามารถโหลดประวัติการอบรมได้' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session, membership } = await requireOrganizationAccess();
const requestMetadata = getRequestMetadata(request);
const body = normalizeTrainingRecordPayload(
(await request.json()) as TrainingRecordMutationPayload
);
const approvalStatus = body.submissionMode === 'draft' ? 'draft' : 'pending';
const { user, course } = await assertUserAndCourseAccess({
organizationId: organization.id,
targetEmployeeId: body.employeeId,
actorUserId: session.user.id,
actorRole: membership.role,
courseId: body.courseId,
courseName: body.courseName
});
if (!user || !course) {
if (!user) {
return NextResponse.json(
{ message: 'ไม่พบผู้ใช้งานในองค์กรที่กำลังใช้งานอยู่' },
{ status: 400 }
);
}
}
let record;
try {
[record] = await db
.insert(trainingRecords)
.values({
organizationId: organization.id,
userId: user.userId ?? null,
employeeId: body.employeeId,
courseId: course?.id ?? null,
courseName: course?.name ?? body.courseName,
trainingDate: new Date(body.trainingDate),
trainingType: body.trainingType,
submittedMinutes: body.submittedMinutes,
hours: body.hours,
organizer: body.organizer,
note: body.note,
approvalStatus,
submittedByUserId: session.user.id,
createdBy: session.user.id
})
.returning();
} catch (error) {
if (!isMissingTrainingRecordMinutesStorage(error)) {
throw error;
}
[record] = await db
.insert(trainingRecords)
.values({
organizationId: organization.id,
userId: user.userId ?? null,
employeeId: body.employeeId,
courseId: course?.id ?? null,
courseName: course?.name ?? body.courseName,
trainingDate: new Date(body.trainingDate),
trainingType: body.trainingType,
hours: body.hours,
organizer: body.organizer,
note: body.note,
approvalStatus,
submittedByUserId: session.user.id,
createdBy: session.user.id
})
.returning();
}
const serializedRecord = serializeTrainingRecord({
id: record.id,
organizationId: record.organizationId,
userId: record.userId,
employeeId: record.employeeId,
employeeCode: user.employeeCode ?? null,
userName: user.fullName,
companyName: user.companyName,
departmentName: null,
courseId: record.courseId,
courseName: record.courseName,
trainingDate: record.trainingDate,
trainingType: record.trainingType,
submittedMinutes: record.submittedMinutes,
hours: record.hours,
approvedMinutes: record.approvedMinutes,
approvedHours: record.approvedHours,
category: record.category,
organizer: record.organizer,
note: record.note,
certificatePreviewUrl: null,
certificatePreviewType: null,
approvalStatus: record.approvalStatus,
reviewerNote: record.reviewerNote,
rejectReason: record.rejectReason,
reviewedBy: record.reviewedBy,
reviewedAt: record.reviewedAt,
createdBy: record.createdBy,
createdByName: session.user.name ?? null,
createdAt: record.createdAt,
updatedAt: record.updatedAt
});
await logAuditEvent({
organizationId: organization.id,
userId: session.user.id,
module: AUDIT_MODULE.TRAINING_RECORDS,
action: AUDIT_ACTION.CREATE,
entityName: record.courseName,
entityId: record.id,
newValue: toAuditValue(serializedRecord),
...requestMetadata
});
return NextResponse.json(
{
success: true,
time: new Date().toISOString(),
message: 'สร้างรายการอบรมเรียบร้อยแล้ว',
training_record: serializedRecord
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
console.error('Failed to create training record', error);
return NextResponse.json({ message: getDatabaseErrorMessage(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { userPermissionAssignmentSchema } from '@/features/permission-management/schemas/user-permission-assignment';
import { saveUserPermissionAssignmentForOrganization } from '@/features/permission-management/server/user-permission-data';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ userId: string }> }
) {
try {
const { organization, session } = await requireSuperAdminOrganizationAccess();
const body = userPermissionAssignmentSchema.parse(await request.json());
await saveUserPermissionAssignmentForOrganization({
organizationId: organization.id,
actorUserId: session.user.id,
userId: (await params).userId,
payload: body
});
return NextResponse.json({
success: true,
message: 'User permission assignment saved successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to save user permission assignment' }, { status: 500 });
}
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { getUserPermissionAssignmentDetailForOrganization } from '@/features/permission-management/server/user-permission-data';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
export async function GET(
_: Request,
{ params }: { params: Promise<{ userId: string }> }
) {
try {
const { organization } = await requireSuperAdminOrganizationAccess();
const detail = await getUserPermissionAssignmentDetailForOrganization({
organizationId: organization.id,
userId: (await params).userId
});
return NextResponse.json({
success: true,
effective_permissions: detail.effective_permissions
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load effective permissions' }, { status: 500 });
}
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server';
import { userPermissionOverrideSchema } from '@/features/permission-management/schemas/user-permission-assignment';
import {
deleteUserPermissionOverrideForOrganization,
updateUserPermissionOverrideForOrganization
} from '@/features/permission-management/server/user-permission-data';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ userId: string; overrideId: string }> }
) {
try {
const { organization, session } = await requireSuperAdminOrganizationAccess();
const body = userPermissionOverrideSchema.parse(await request.json());
const routeParams = await params;
await updateUserPermissionOverrideForOrganization({
organizationId: organization.id,
actorUserId: session.user.id,
userId: routeParams.userId,
overrideId: Number(routeParams.overrideId),
payload: body
});
return NextResponse.json({
success: true,
message: 'User permission override updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update permission override' }, { status: 500 });
}
}
export async function DELETE(
_: NextRequest,
{ params }: { params: Promise<{ userId: string; overrideId: string }> }
) {
try {
const { organization, session } = await requireSuperAdminOrganizationAccess();
const routeParams = await params;
await deleteUserPermissionOverrideForOrganization({
organizationId: organization.id,
actorUserId: session.user.id,
userId: routeParams.userId,
overrideId: Number(routeParams.overrideId)
});
return NextResponse.json({
success: true,
message: 'User permission override removed successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to remove permission override' }, { status: 500 });
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { userPermissionOverrideSchema } from '@/features/permission-management/schemas/user-permission-assignment';
import { createUserPermissionOverrideForOrganization } from '@/features/permission-management/server/user-permission-data';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ userId: string }> }
) {
try {
const { organization, session } = await requireSuperAdminOrganizationAccess();
const body = userPermissionOverrideSchema.parse(await request.json());
const overrideId = await createUserPermissionOverrideForOrganization({
organizationId: organization.id,
actorUserId: session.user.id,
userId: (await params).userId,
payload: body
});
return NextResponse.json(
{
success: true,
message: 'User permission override created successfully',
override_id: overrideId
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create permission override' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { getUserPermissionAssignmentDetailForOrganization } from '@/features/permission-management/server/user-permission-data';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
export async function GET(
_: Request,
{ params }: { params: Promise<{ userId: string }> }
) {
try {
const { organization } = await requireSuperAdminOrganizationAccess();
const detail = await getUserPermissionAssignmentDetailForOrganization({
organizationId: organization.id,
userId: (await params).userId
});
return NextResponse.json({
success: true,
detail
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error && error.message === 'NOT_FOUND') {
return NextResponse.json({ message: 'User permission detail not found' }, { status: 404 });
}
return NextResponse.json({ message: 'Unable to load user permission detail' }, { status: 500 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { listUserPermissionAssignmentsForOrganization } from '@/features/permission-management/server/user-permission-data';
import { AuthError, requireSuperAdminOrganizationAccess } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireSuperAdminOrganizationAccess();
const result = await listUserPermissionAssignmentsForOrganization({
organizationId: organization.id,
filters: {
page: Number(request.nextUrl.searchParams.get('page') ?? 1),
limit: Number(request.nextUrl.searchParams.get('limit') ?? 10),
search: request.nextUrl.searchParams.get('search') ?? undefined,
sort: request.nextUrl.searchParams.get('sort') ?? undefined
}
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Loaded user permission assignments successfully',
total_user_permissions: result.total,
offset: result.offset,
limit: result.limit,
user_permissions: result.userPermissions
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load user permissions' }, { status: 500 });
}
}

View File

@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthError, requireUserManagementAccess } from '@/lib/auth/session';
import {
deactivateUserForAccess,
getUserForAccess,
updateUserForAccess
} from '@/features/users/server/user-data';
type Params = { params: Promise<{ id: string }> };
function getAccessContext(
access: Awaited<ReturnType<typeof requireUserManagementAccess>>
) {
return {
currentUserId: access.session.user.id,
currentOrganizationId: access.organization.id,
membershipRole: access.membership.role,
businessRole: access.session.user.businessRole,
systemRole: access.session.user.systemRole
};
}
export async function GET(_request: NextRequest, { params }: Params) {
try {
const userManagementAccess = await requireUserManagementAccess();
const access = getAccessContext(userManagementAccess);
const { id } = await params;
const user = await getUserForAccess({ id, access });
if (!user) {
return NextResponse.json({ message: 'User not found' }, { status: 404 });
}
return NextResponse.json({
success: true,
message: 'User loaded successfully',
user
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error && error.message === 'FORBIDDEN') {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
return NextResponse.json({ message: 'Unable to load user' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const userManagementAccess = await requireUserManagementAccess();
const access = getAccessContext(userManagementAccess);
const { id } = await params;
const body = await request.json();
await updateUserForAccess({
userId: id,
payload: body,
access
});
return NextResponse.json({
success: true,
message: 'User updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
if (error.message === 'FORBIDDEN' || error.message === 'ADMIN_ROLE_FORBIDDEN') {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
if (error.message === 'USERNAME_EXISTS') {
return NextResponse.json({ message: 'Username already exists' }, { status: 400 });
}
if (error.message === 'NOT_FOUND') {
return NextResponse.json({ message: 'User not found' }, { status: 404 });
}
}
return NextResponse.json({ message: 'Unable to update user' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const userManagementAccess = await requireUserManagementAccess();
const access = getAccessContext(userManagementAccess);
const { id } = await params;
await deactivateUserForAccess({
userId: id,
access
});
return NextResponse.json({
success: true,
message: 'User deactivated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
if (error.message === 'FORBIDDEN') {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
if (error.message === 'NOT_FOUND') {
return NextResponse.json({ message: 'User not found' }, { status: 404 });
}
}
return NextResponse.json({ message: 'Unable to deactivate user' }, { status: 500 });
}
}

121
src/app/api/users/route.ts Normal file
View File

@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthError, requireUserManagementAccess } from '@/lib/auth/session';
import {
getUserMasterOptionsForAccess,
listUsersForAccess,
upsertUserForAccess
} from '@/features/users/server/user-data';
function getAccessContext(
access: Awaited<ReturnType<typeof requireUserManagementAccess>>
) {
return {
currentUserId: access.session.user.id,
currentOrganizationId: access.organization.id,
membershipRole: access.membership.role,
businessRole: access.session.user.businessRole,
systemRole: access.session.user.systemRole
};
}
export async function GET(request: NextRequest) {
try {
const userManagementAccess = await requireUserManagementAccess();
const access = getAccessContext(userManagementAccess);
const mode = request.nextUrl.searchParams.get('mode');
if (mode === 'options') {
const options = await getUserMasterOptionsForAccess({ access });
return NextResponse.json(options);
}
const { searchParams } = request.nextUrl;
const result = await listUsersForAccess({
filters: {
page: Number(searchParams.get('page') ?? 1),
limit: Number(searchParams.get('limit') ?? 10),
roles: searchParams.get('roles') ?? undefined,
search: searchParams.get('search') ?? undefined,
sort: searchParams.get('sort') ?? undefined
},
access
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Users loaded successfully',
total_users: result.total,
offset: result.offset,
limit: result.limit,
users: result.users
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error && error.message === 'FORBIDDEN') {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
console.error('GET /api/users failed', error);
return NextResponse.json({ message: 'Unable to load users' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const userManagementAccess = await requireUserManagementAccess();
const access = getAccessContext(userManagementAccess);
const body = await request.json();
const userId = await upsertUserForAccess({
payload: body,
access
});
return NextResponse.json(
{
success: true,
message: 'User saved successfully',
user_id: userId
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
if (error.message === 'FORBIDDEN') {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
if (error.message === 'ORGANIZATION_REQUIRED') {
return NextResponse.json({ message: 'Organizer is required' }, { status: 400 });
}
if (error.message === 'PASSWORD_REQUIRED') {
return NextResponse.json(
{ message: 'Password is required and must be at least 8 characters' },
{ status: 400 }
);
}
if (error.message === 'USERNAME_EXISTS') {
return NextResponse.json({ message: 'Username already exists' }, { status: 400 });
}
if (error.message === 'ADMIN_ROLE_FORBIDDEN') {
return NextResponse.json(
{ message: 'Only super admins can assign admin access' },
{ status: 403 }
);
}
}
return NextResponse.json({ message: 'Unable to save user' }, { status: 500 });
}
}