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

80
src/app/about/page.tsx Normal file
View File

@@ -0,0 +1,80 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About'
};
export default function AboutPage() {
return (
<div className='min-h-screen px-4 py-12 sm:px-6 lg:px-8'>
<div className='mx-auto max-w-3xl'>
{/* Header */}
<div className='mb-12 text-center'>
<h1 className='text-foreground text-3xl font-bold tracking-tight sm:text-4xl'>About</h1>
<p className='text-muted-foreground mt-4 text-lg'>Learn more about this project</p>
</div>
{/* Content Sections */}
<div className='space-y-8'>
{/* Open Source Section */}
<section className='bg-card rounded-2xl border p-8 shadow-sm'>
<h2 className='text-foreground mb-4 text-xl font-semibold'>Open-Source Project</h2>
<p className='text-muted-foreground text-lg leading-relaxed'>
This is an open-source Next.js admin dashboard starter built with modern web
technologies. It provides a solid foundation for building powerful admin interfaces
and dashboards. The source code is freely available for developers to use, modify, and
distribute.
</p>
</section>
{/* Demo Purpose Section */}
<section className='bg-card rounded-2xl border p-8 shadow-sm'>
<h2 className='text-foreground mb-4 text-xl font-semibold'>Demo Purpose</h2>
<p className='text-muted-foreground text-lg leading-relaxed'>
This application serves as a demo for demonstration purposes. It showcases the
features, components, and capabilities of the admin dashboard starter. Feel free to
explore the interface, test the functionality, and evaluate if it meets your project
requirements.
</p>
</section>
{/* Auth Section */}
<section className='bg-card rounded-2xl border p-8 shadow-sm'>
<h2 className='text-foreground mb-4 text-xl font-semibold'>Authentication by Auth.js</h2>
<p className='text-muted-foreground text-lg leading-relaxed'>
Authentication for this application is now handled by{' '}
<a
href='https://authjs.dev'
target='_blank'
rel='noopener noreferrer'
className='text-primary font-medium hover:underline'
>
Auth.js
</a>
. The app owns its user, workspace, membership, and product data model directly in
Postgres, which keeps authentication and authorization logic inside the codebase.
</p>
</section>
{/* Data Privacy Section */}
<section className='bg-card rounded-2xl border p-8 shadow-sm'>
<h2 className='text-foreground mb-4 text-xl font-semibold'>Data Privacy</h2>
<p className='text-muted-foreground text-lg leading-relaxed'>
We take your privacy seriously. No personal data is misused, shared, or sold to third
parties. Any information collected during your use of this demo application is used
solely for the purpose of providing the demonstration experience and is handled in
accordance with best practices for data protection.
</p>
</section>
</div>
{/* Footer Note */}
<div className='mt-12 text-center'>
<p className='text-muted-foreground text-sm'>
Built with Next.js, Tailwind CSS, and shadcn/ui
</p>
</div>
</div>
</div>
);
}

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 });
}
}

12
src/app/auth/layout.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
robots: {
index: false,
follow: false
}
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return children;
}

13
src/app/auth/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
export const metadata: Metadata = {
robots: {
index: false,
follow: false
}
};
export default function AuthPage() {
redirect('/auth/sign-in');
}

View File

@@ -0,0 +1,11 @@
import { Metadata } from 'next';
import SignInViewPage from '@/features/auth/components/sign-in-view';
export const metadata: Metadata = {
title: 'Authentication | Sign In',
description: 'Sign In page for authentication.'
};
export default async function Page() {
return <SignInViewPage />;
}

View File

@@ -0,0 +1,11 @@
import { Metadata } from 'next';
import SignUpViewPage from '@/features/auth/components/sign-up-view';
export const metadata: Metadata = {
title: 'Authentication | Sign Up Disabled',
description: 'Sign-up is disabled. Accounts must be created by an administrator.'
};
export default async function Page() {
return <SignUpViewPage />;
}

View File

@@ -0,0 +1,29 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { Icons } from '@/components/icons';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
export default function AnnouncementDetailError({
error,
reset
}: {
error: Error;
reset: () => void;
}) {
return (
<PageContainer pageTitle='ประกาศ' pageDescription='เกิดข้อผิดพลาดระหว่างโหลดรายละเอียดประกาศ'>
<Alert variant='destructive' className='max-w-3xl'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle></AlertTitle>
<AlertDescription className='space-y-3'>
<p>{error.message}</p>
<Button variant='outline' onClick={reset}>
</Button>
</AlertDescription>
</Alert>
</PageContainer>
);
}

View File

@@ -0,0 +1,26 @@
import PageContainer from '@/components/layout/page-container';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<PageContainer pageTitle='ประกาศ' pageDescription='กำลังโหลดรายละเอียดประกาศ...'>
<Card>
<CardHeader className='space-y-4'>
<div className='flex gap-2'>
<Skeleton className='h-6 w-24' />
<Skeleton className='h-6 w-20' />
</div>
<Skeleton className='h-8 w-3/4' />
<Skeleton className='h-4 w-1/2' />
</CardHeader>
<CardContent className='space-y-4'>
<Skeleton className='h-4 w-full' />
<Skeleton className='h-4 w-full' />
<Skeleton className='h-4 w-5/6' />
<Skeleton className='h-24 w-full' />
</CardContent>
</Card>
</PageContainer>
);
}

View File

@@ -0,0 +1,49 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { notFound, redirect } from 'next/navigation';
import PageContainer from '@/components/layout/page-container';
import { announcementByIdOptions } from '@/features/announcements/api/queries';
import AnnouncementViewPage from '@/features/announcements/components/announcement-view-page';
import { hasAnyPermission } from '@/lib/auth/authorization';
import { requireEmployeeDashboardAccess } from '@/lib/auth/page-guards';
import { getQueryClient } from '@/lib/query-client';
export const metadata = {
title: 'Dashboard : Announcement Detail'
};
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function Page(props: PageProps) {
const access = await requireEmployeeDashboardAccess();
const params = await props.params;
const permissions = access.session.user.effectivePermissions;
const canCreateAnnouncements = hasAnyPermission(permissions, ['announcement:create']);
if (params.id === 'new' && !canCreateAnnouncements) {
redirect('/dashboard/announcements');
}
if (params.id !== 'new' && Number.isNaN(Number(params.id))) {
notFound();
}
const queryClient = getQueryClient();
if (params.id !== 'new') {
await queryClient.fetchQuery(announcementByIdOptions(Number(params.id)));
}
return (
<PageContainer>
<HydrationBoundary state={dehydrate(queryClient)}>
<AnnouncementViewPage
announcementId={params.id}
currentUserId={access.session.user.id}
permissions={permissions}
/>
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { Icons } from '@/components/icons';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
export default function AnnouncementsError({
error,
reset
}: {
error: Error;
reset: () => void;
}) {
return (
<PageContainer pageTitle='ประกาศ' pageDescription='เกิดข้อผิดพลาดระหว่างโหลดรายการประกาศ'>
<Alert variant='destructive' className='max-w-3xl'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle></AlertTitle>
<AlertDescription className='space-y-3'>
<p>{error.message}</p>
<Button variant='outline' onClick={reset}>
</Button>
</AlertDescription>
</Alert>
</PageContainer>
);
}

View File

@@ -0,0 +1,32 @@
import PageContainer from '@/components/layout/page-container';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<PageContainer pageTitle='ประกาศ' pageDescription='กำลังโหลดประกาศและไฟล์แนบล่าสุด...'>
<div className='space-y-6'>
<div className='flex flex-wrap gap-2'>
<Skeleton className='h-10 w-40' />
<Skeleton className='h-10 w-32' />
<Skeleton className='h-10 w-24' />
</div>
<div className='space-y-4'>
{Array.from({ length: 4 }).map((_, index) => (
<Card key={index}>
<CardHeader className='space-y-3'>
<Skeleton className='h-6 w-64' />
<Skeleton className='h-4 w-48' />
</CardHeader>
<CardContent className='space-y-3'>
<Skeleton className='h-4 w-full' />
<Skeleton className='h-4 w-full' />
<Skeleton className='h-4 w-2/3' />
</CardContent>
</Card>
))}
</div>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,48 @@
import type { SearchParams } from 'nuqs/server';
import Link from 'next/link';
import PageContainer from '@/components/layout/page-container';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import AnnouncementsListingPage from '@/features/announcements/components/announcements-listing';
import { hasAnyPermission } from '@/lib/auth/authorization';
import { requireEmployeeDashboardAccess } from '@/lib/auth/page-guards';
import { searchParamsCache } from '@/lib/searchparams';
export const metadata = {
title: 'Dashboard : Announcements'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function Page(props: PageProps) {
const access = await requireEmployeeDashboardAccess();
const searchParams = await props.searchParams;
searchParamsCache.parse(searchParams);
const permissions = access.session.user.effectivePermissions;
const canCreateAnnouncements = hasAnyPermission(permissions, ['announcement:create']);
return (
<PageContainer
pageTitle='ประกาศ'
pageDescription='ติดตามประกาศสำคัญขององค์กร พร้อมจัดการร่างประกาศ การเผยแพร่ และการปักหมุด'
pageHeaderAction={
canCreateAnnouncements ? (
<Button asChild>
<Link href='/dashboard/announcements/new'>
<Icons.add className='h-4 w-4' />
</Link>
</Button>
) : null
}
>
<AnnouncementsListingPage
currentUserId={access.session.user.id}
permissions={permissions}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,29 @@
import type { SearchParams } from 'nuqs/server';
import PageContainer from '@/components/layout/page-container';
import AuditLogListingPage from '@/features/audit-logs/components/audit-log-listing';
import { requireITDashboardAccess } from '@/lib/auth/page-guards';
import { searchParamsCache } from '@/lib/searchparams';
export const metadata = {
title: 'แดชบอร์ด: Audit Log'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function Page(props: PageProps) {
await requireITDashboardAccess();
const searchParams = await props.searchParams;
searchParamsCache.parse(searchParams);
return (
<PageContainer
pageTitle='Audit Log'
pageDescription='ตรวจสอบประวัติการใช้งานและรายการสำคัญของระบบ'
>
<AuditLogListingPage />
</PageContainer>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Icons } from '@/components/icons';
import { billingInfoContent } from '@/config/infoconfig';
import { useSession } from 'next-auth/react';
export default function BillingPage() {
const { data: session, status } = useSession();
const activeOrganization = session?.user?.organizations.find(
(organization) => organization.id === session?.user?.activeOrganizationId
);
return (
<PageContainer
isLoading={status === 'loading'}
access={!!activeOrganization}
accessFallback={
<div className='flex min-h-[400px] items-center justify-center'>
<div className='space-y-2 text-center'>
<h2 className='text-2xl font-semibold'>No Workspace Selected</h2>
<p className='text-muted-foreground'>
Please select or create a workspace to view billing placeholders.
</p>
</div>
</div>
}
infoContent={billingInfoContent}
pageTitle='Billing & Plans'
pageDescription={`Review the current plan state for ${activeOrganization?.name ?? 'your workspace'}`}
>
<div className='space-y-6'>
<Alert>
<Icons.info className='h-4 w-4' />
<AlertDescription>
Clerk Billing has been removed from this route. Plan enforcement now reads app-owned
workspace data, and payment provider integration can be added later.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Current plan</CardTitle>
<CardDescription>
This page is intentionally lightweight while billing is no longer vendor-managed.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-sm'>Workspace</div>
<div className='text-lg font-semibold'>{activeOrganization?.name}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-sm'>Plan</div>
<div className='text-lg font-semibold capitalize'>
{activeOrganization?.plan ?? 'free'}
</div>
</div>
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
Add subscriptions, invoices, and provider sync in a later pass. The `organizations`
table is already the source of truth for plan gates.
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,9 @@
import ChatViewPage from '@/features/chat/components/chat-view-page';
export const metadata = {
title: 'Dashboard: Chat'
};
export default function Page() {
return <ChatViewPage />;
}

View File

@@ -0,0 +1,26 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function Error({
reset
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<PageContainer>
<Card>
<CardHeader>
<CardTitle>Unable to load course details</CardTitle>
<CardDescription>Refresh this page and try again.</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={reset}>Try again</Button>
</CardContent>
</Card>
</PageContainer>
);
}

View File

@@ -0,0 +1,21 @@
import PageContainer from '@/components/layout/page-container';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<PageContainer>
<Card>
<CardHeader className='space-y-2'>
<Skeleton className='h-8 w-48' />
<Skeleton className='h-4 w-72' />
</CardHeader>
<CardContent className='grid grid-cols-1 gap-6 md:grid-cols-2'>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton key={index} className='h-10 w-full' />
))}
</CardContent>
</Card>
</PageContainer>
);
}

View File

@@ -0,0 +1,33 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { courseByIdOptions } from '@/features/courses/api/queries';
import PageContainer from '@/components/layout/page-container';
import CourseViewPage from '@/features/courses/components/course-view-page';
import { requireHRDDashboardAccess } from '@/lib/auth/page-guards';
export const metadata = {
title: 'Dashboard : Course View'
};
type PageProps = { params: Promise<{ courseId: string }> };
export default async function Page(props: PageProps) {
await requireHRDDashboardAccess();
const params = await props.params;
const queryClient = getQueryClient();
if (params.courseId !== 'new') {
await queryClient.fetchQuery(courseByIdOptions(Number(params.courseId)));
}
return (
<PageContainer>
<div className='flex-1 space-y-4'>
<HydrationBoundary state={dehydrate(queryClient)}>
<CourseViewPage courseId={params.courseId} />
</HydrationBoundary>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function Error({
reset
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<PageContainer
pageTitle='หลักสูตร'
pageDescription='จัดการหลักสูตร ระยะเวลารับรอง และกติกาการอบรมที่จำเป็น'
>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Button onClick={reset}></Button>
</CardContent>
</Card>
</PageContainer>
);
}

View File

@@ -0,0 +1,29 @@
import PageContainer from '@/components/layout/page-container';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<PageContainer
pageTitle='หลักสูตร'
pageDescription='กำลังโหลดข้อมูลหลักสูตร ระยะเวลารับรอง และกติกาการอบรม...'
>
<Card>
<CardHeader className='space-y-2'>
<Skeleton className='h-8 w-48' />
<Skeleton className='h-4 w-72' />
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex gap-2'>
<Skeleton className='h-8 w-56' />
<Skeleton className='h-8 w-36' />
<Skeleton className='h-8 w-36' />
</div>
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className='h-12 w-full' />
))}
</CardContent>
</Card>
</PageContainer>
);
}

View File

@@ -0,0 +1,41 @@
import PageContainer from "@/components/layout/page-container";
import { buttonVariants } from "@/components/ui/button";
import CourseListingPage from "@/features/courses/components/course-listing";
import { requireHRDDashboardAccess } from "@/lib/auth/page-guards";
import { searchParamsCache } from "@/lib/searchparams";
import { cn } from "@/lib/utils";
import { Icons } from "@/components/icons";
import Link from "next/link";
import { SearchParams } from "nuqs/server";
export const metadata = {
title: "แดชบอร์ด: หลักสูตร",
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function Page(props: PageProps) {
await requireHRDDashboardAccess();
const searchParams = await props.searchParams;
searchParamsCache.parse(searchParams);
return (
<PageContainer
pageTitle="หลักสูตร"
pageDescription="จัดการหลักสูตรภายในบริษัท"
pageHeaderAction={
<Link
href="/dashboard/courses/new"
className={cn(buttonVariants(), "text-xs md:text-sm")}
>
<Icons.add className="mr-2 h-4 w-4" />
</Link>
}
>
<CourseListingPage />
</PageContainer>
);
}

View File

@@ -0,0 +1,9 @@
import IconsViewPage from '@/features/elements/components/icons-view-page';
export const metadata = {
title: 'Dashboard : Icons'
};
export default function page() {
return <IconsViewPage />;
}

View File

@@ -0,0 +1,40 @@
import { notFound } from 'next/navigation';
import PageContainer from '@/components/layout/page-container';
import EmployeeForm from '@/features/employees/components/employee-form';
import {
getEmployeeByIdForOrganization,
serializeEmployee
} from '@/features/employees/server/employee-data';
import { requireHRDDashboardAccess } from '@/lib/auth/page-guards';
export const metadata = {
title: 'Dashboard: Edit Employee'
};
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function Page(props: PageProps) {
const access = await requireHRDDashboardAccess();
const params = await props.params;
const employee = await getEmployeeByIdForOrganization({
id: Number(params.id),
organizationId: access.organization.id
});
if (!employee) {
notFound();
}
return (
<PageContainer pageTitle='แก้ไขข้อมูลพนักงาน' pageDescription='ปรับปรุงข้อมูล Employee Master'>
<EmployeeForm
initialData={serializeEmployee(employee)}
pageTitle='แก้ไขข้อมูลพนักงาน'
organizationName={access.organization.name}
returnPath='/dashboard/employee-directory'
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,28 @@
import PageContainer from '@/components/layout/page-container';
import { EmployeeDirectoryDetailPage } from '@/features/employee-directory/components/employee-directory-detail-page';
import { requireHRDDashboardAccess } from '@/lib/auth/page-guards';
export const metadata = {
title: 'Dashboard: Employee Directory Detail'
};
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function Page(props: PageProps) {
const access = await requireHRDDashboardAccess();
const params = await props.params;
return (
<PageContainer
pageTitle='รายชื่อพนักงานทั้งหมด'
pageDescription='รายละเอียดข้อมูลพนักงาน ชั่วโมงอบรม และประวัติการอบรมรายบุคคล'
>
<EmployeeDirectoryDetailPage
organizationId={access.organization.id}
employeeId={params.id}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,35 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { employeeDirectoryTargetQueryOptions } from '@/features/employee-directory/api/queries';
import { EmployeeTargetForm } from '@/features/employee-directory/components/employee-target-form';
import { requireHRDDashboardAccess } from '@/lib/auth/page-guards';
import { getQueryClient } from '@/lib/query-client';
export const metadata = {
title: 'Dashboard: Employee Targets'
};
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function Page(props: PageProps) {
await requireHRDDashboardAccess();
const params = await props.params;
const year = new Date().getUTCFullYear();
const employeeId = Number(params.id);
const queryClient = getQueryClient();
await queryClient.fetchQuery(employeeDirectoryTargetQueryOptions(employeeId, year));
return (
<PageContainer
pageTitle='กำหนดเป้าหมาย K / S / A'
pageDescription='จัดการเป้าหมายชั่วโมงอบรมรายบุคคล'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<EmployeeTargetForm employeeId={employeeId} year={year} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,25 @@
import PageContainer from '@/components/layout/page-container';
import EmployeeForm from '@/features/employees/components/employee-form';
import { requireHRDDashboardAccess } from '@/lib/auth/page-guards';
export const metadata = {
title: 'Dashboard: Create Employee'
};
export default async function Page() {
const access = await requireHRDDashboardAccess();
return (
<PageContainer
pageTitle='สร้างรายชื่อพนักงาน'
pageDescription='เพิ่มข้อมูลพนักงานเข้าสู่ Employee Directory'
>
<EmployeeForm
initialData={null}
pageTitle='สร้างรายชื่อพนักงาน'
organizationName={access.organization.name}
returnPath='/dashboard/employee-directory'
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,46 @@
import PageContainer from '@/components/layout/page-container';
import { EmployeeDirectoryHeader } from '@/features/employee-directory/components/employee-directory-header';
import EmployeeDirectoryListingPage from '@/features/employee-directory/components/employee-directory-listing';
import { requireHRDDashboardAccess } from '@/lib/auth/page-guards';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
export const metadata = {
title: 'Dashboard: Employee Directory'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function Page(props: PageProps) {
await requireHRDDashboardAccess();
const searchParams = await props.searchParams;
searchParamsCache.parse(searchParams);
const search = searchParamsCache.get('search');
const company = searchParamsCache.get('company');
const department = searchParamsCache.get('department');
const position = searchParamsCache.get('position');
const status = searchParamsCache.get('status');
const sort = searchParamsCache.get('sort');
const filters = {
page: searchParamsCache.get('page'),
limit: searchParamsCache.get('perPage'),
...(search ? { search } : {}),
...(company ? { company } : {}),
...(department ? { department } : {}),
...(position ? { position } : {}),
...(status ? { status } : {}),
...(sort ? { sort } : {})
};
return (
<PageContainer>
<div className='flex min-w-0 flex-1 flex-col gap-4 md:gap-5'>
<EmployeeDirectoryHeader />
<EmployeeDirectoryListingPage filters={filters} />
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,11 @@
import { redirect } from 'next/navigation';
import { requireHRDDashboardAccess } from '@/lib/auth/page-guards';
export const metadata = {
title: 'Dashboard: Users'
};
export default async function Page() {
await requireHRDDashboardAccess();
redirect('/dashboard/users');
}

View File

@@ -0,0 +1,11 @@
import { redirect } from 'next/navigation';
import { requireHRDDashboardAccess } from '@/lib/auth/page-guards';
export const metadata = {
title: 'Dashboard: Users'
};
export default async function Page() {
await requireHRDDashboardAccess();
redirect('/dashboard/users');
}

View File

@@ -0,0 +1,61 @@
import PageContainer from '@/components/layout/page-container';
import { auth } from '@/auth';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Icons } from '@/components/icons';
import Link from 'next/link';
export default async function ExclusivePage() {
const session = await auth();
const activeOrganizationName = session?.user?.activeOrganizationName;
const activePlan = session?.user?.activeOrganizationPlan;
return (
<PageContainer>
{activePlan !== 'pro' ? (
<div className='flex h-full items-center justify-center'>
<Alert>
<Icons.lock className='h-5 w-5 text-yellow-600' />
<AlertDescription>
<div className='mb-1 text-lg font-semibold'>Pro Plan Required</div>
<div className='text-muted-foreground'>
This page is only available to workspaces on the <span className='font-semibold'>Pro</span>{' '}
plan.
<br />
Upgrade your workspace later through the app-owned{' '}
<Link className='underline' href='/dashboard/billing'>
Billing &amp; Plans
</Link>{' '}
flow.
</div>
</AlertDescription>
</Alert>
</div>
) : (
<div className='space-y-6'>
<div>
<h1 className='flex items-center gap-2 text-3xl font-bold tracking-tight'>
<Icons.badgeCheck className='h-7 w-7 text-green-600' />
Exclusive Area
</h1>
<p className='text-muted-foreground'>
Welcome, <span className='font-semibold'>{activeOrganizationName}</span>! This page
remains gated by the workspace plan stored in your own database.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Exclusive content enabled</CardTitle>
<CardDescription>
Your active workspace currently satisfies the `pro` plan gate.
</CardDescription>
</CardHeader>
<CardContent>
<div className='text-lg'>Have a wonderful day!</div>
</CardContent>
</Card>
</div>
)}
</PageContainer>
);
}

View File

@@ -0,0 +1,17 @@
import PageContainer from '@/components/layout/page-container';
import AdvancedFormPatterns from '@/features/forms/components/advanced-form-patterns';
export const metadata = {
title: 'Dashboard: Advanced Form Patterns'
};
export default function Page() {
return (
<PageContainer
pageTitle='Advanced Form Patterns'
pageDescription='Linked fields, async validation, dynamic rows, nested objects, cross-field validation, and form-level errors.'
>
<AdvancedFormPatterns />
</PageContainer>
);
}

View File

@@ -0,0 +1,17 @@
import PageContainer from '@/components/layout/page-container';
import DemoForm from '@/components/forms/demo-form';
export const metadata = {
title: 'Dashboard: Basic Form'
};
export default function Page() {
return (
<PageContainer
pageTitle='Basic Form'
pageDescription='A comprehensive form demo with all field types.'
>
<DemoForm />
</PageContainer>
);
}

View File

@@ -0,0 +1,14 @@
import PageContainer from '@/components/layout/page-container';
import FormsShowcasePage from '@/features/forms/components/forms-showcase-page';
export const metadata = {
title: 'Dashboard: Multi-Step Form'
};
export default function Page() {
return (
<PageContainer pageTitle='Multi-Step Form' pageDescription='Multi-step wizard form pattern.'>
<FormsShowcasePage />
</PageContainer>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/dashboard/forms/basic');
}

View File

@@ -0,0 +1,17 @@
import PageContainer from '@/components/layout/page-container';
import SheetFormDemo from '@/features/forms/components/sheet-form-demo';
export const metadata = {
title: 'Dashboard: Sheet Form'
};
export default function Page() {
return (
<PageContainer
pageTitle='Sheet & Dialog Forms'
pageDescription='Form patterns inside sheets and dialogs with external submit buttons.'
>
<SheetFormDemo />
</PageContainer>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { Icons } from '@/components/icons';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
export default function ImportEmployeesError({
error,
reset
}: {
error: Error;
reset: () => void;
}) {
return (
<PageContainer pageTitle='นำเข้าพนักงาน' pageDescription='เกิดข้อผิดพลาดระหว่างโหลดหน้าจอนำเข้า'>
<Alert variant='destructive' className='max-w-3xl'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle></AlertTitle>
<AlertDescription className='space-y-3'>
<p>{error.message}</p>
<Button variant='outline' onClick={reset}>
</Button>
</AlertDescription>
</Alert>
</PageContainer>
);
}

View File

@@ -0,0 +1,36 @@
import PageContainer from '@/components/layout/page-container';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<PageContainer pageTitle='นำเข้าพนักงาน' pageDescription='กำลังโหลดหน้าจอนำเข้าพนักงาน...'>
<div className='grid gap-4 lg:grid-cols-[1.1fr_0.9fr]'>
<Card>
<CardHeader className='space-y-3'>
<Skeleton className='h-7 w-48' />
<Skeleton className='h-4 w-full' />
</CardHeader>
<CardContent className='space-y-4'>
<Skeleton className='h-10 w-full' />
<div className='flex gap-2'>
<Skeleton className='h-10 w-40' />
<Skeleton className='h-10 w-40' />
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='space-y-3'>
<Skeleton className='h-7 w-48' />
<Skeleton className='h-4 w-full' />
</CardHeader>
<CardContent className='space-y-2'>
{Array.from({ length: 7 }).map((_, index) => (
<Skeleton key={index} className='h-10 w-full' />
))}
</CardContent>
</Card>
</div>
</PageContainer>
);
}

Some files were not shown because too many files have changed in this diff Show More