commit
This commit is contained in:
70
src/app/api/announcements/[id]/attachment/route.ts
Normal file
70
src/app/api/announcements/[id]/attachment/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
122
src/app/api/announcements/[id]/pin/route.ts
Normal file
122
src/app/api/announcements/[id]/pin/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
765
src/app/api/announcements/[id]/route.ts
Normal file
765
src/app/api/announcements/[id]/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
108
src/app/api/announcements/[id]/unpin/route.ts
Normal file
108
src/app/api/announcements/[id]/unpin/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
292
src/app/api/announcements/route.ts
Normal file
292
src/app/api/announcements/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
64
src/app/api/audit-logs/export/route.ts
Normal file
64
src/app/api/audit-logs/export/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
42
src/app/api/audit-logs/route.ts
Normal file
42
src/app/api/audit-logs/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { handlers } from '@/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
12
src/app/api/auth/register/route.ts
Normal file
12
src/app/api/auth/register/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
149
src/app/api/courses/[id]/route.ts
Normal file
149
src/app/api/courses/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
129
src/app/api/courses/route.ts
Normal file
129
src/app/api/courses/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
107
src/app/api/employee-directory/[id]/target/route.ts
Normal file
107
src/app/api/employee-directory/[id]/target/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
40
src/app/api/employee-directory/route.ts
Normal file
40
src/app/api/employee-directory/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
100
src/app/api/employees/[id]/route.ts
Normal file
100
src/app/api/employees/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
77
src/app/api/employees/route.ts
Normal file
77
src/app/api/employees/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
782
src/app/api/import-employees/route.ts
Normal file
782
src/app/api/import-employees/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
69
src/app/api/import-employees/template/route.ts
Normal file
69
src/app/api/import-employees/template/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
371
src/app/api/master-review/route.ts
Normal file
371
src/app/api/master-review/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
src/app/api/notifications/[id]/read/route.ts
Normal file
34
src/app/api/notifications/[id]/read/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
25
src/app/api/notifications/read-all/route.ts
Normal file
25
src/app/api/notifications/read-all/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
58
src/app/api/notifications/route.ts
Normal file
58
src/app/api/notifications/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
25
src/app/api/online-lessons/[id]/attachment/download/route.ts
Normal file
25
src/app/api/online-lessons/[id]/attachment/download/route.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
25
src/app/api/online-lessons/[id]/attachment/view/route.ts
Normal file
25
src/app/api/online-lessons/[id]/attachment/view/route.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
690
src/app/api/online-lessons/[id]/route.ts
Normal file
690
src/app/api/online-lessons/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
377
src/app/api/online-lessons/route.ts
Normal file
377
src/app/api/online-lessons/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
96
src/app/api/organizations/[id]/route.ts
Normal file
96
src/app/api/organizations/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
73
src/app/api/organizations/active/route.ts
Normal file
73
src/app/api/organizations/active/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
124
src/app/api/organizations/route.ts
Normal file
124
src/app/api/organizations/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/permission-audit-logs/route.ts
Normal file
38
src/app/api/permission-audit-logs/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
33
src/app/api/permission-templates/[id]/activate/route.ts
Normal file
33
src/app/api/permission-templates/[id]/activate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
33
src/app/api/permission-templates/[id]/clone/route.ts
Normal file
33
src/app/api/permission-templates/[id]/clone/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
33
src/app/api/permission-templates/[id]/deactivate/route.ts
Normal file
33
src/app/api/permission-templates/[id]/deactivate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
107
src/app/api/permission-templates/[id]/route.ts
Normal file
107
src/app/api/permission-templates/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
70
src/app/api/permission-templates/route.ts
Normal file
70
src/app/api/permission-templates/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
16
src/app/api/permissions/catalog/route.ts
Normal file
16
src/app/api/permissions/catalog/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
128
src/app/api/products/[id]/route.ts
Normal file
128
src/app/api/products/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
146
src/app/api/products/route.ts
Normal file
146
src/app/api/products/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
90
src/app/api/reports/export/route.ts
Normal file
90
src/app/api/reports/export/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
165
src/app/api/training-matrix/[id]/route.ts
Normal file
165
src/app/api/training-matrix/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
src/app/api/training-matrix/compliance/route.ts
Normal file
31
src/app/api/training-matrix/compliance/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
25
src/app/api/training-matrix/options/route.ts
Normal file
25
src/app/api/training-matrix/options/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
114
src/app/api/training-matrix/route.ts
Normal file
114
src/app/api/training-matrix/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
95
src/app/api/training-policies/[id]/activate/route.ts
Normal file
95
src/app/api/training-policies/[id]/activate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
86
src/app/api/training-policies/[id]/clone/route.ts
Normal file
86
src/app/api/training-policies/[id]/clone/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
80
src/app/api/training-policies/[id]/deactivate/route.ts
Normal file
80
src/app/api/training-policies/[id]/deactivate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
src/app/api/training-policies/[id]/history/route.ts
Normal file
31
src/app/api/training-policies/[id]/history/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
155
src/app/api/training-policies/[id]/route.ts
Normal file
155
src/app/api/training-policies/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
144
src/app/api/training-policies/route.ts
Normal file
144
src/app/api/training-policies/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
173
src/app/api/training-records/[id]/certificates/route.ts
Normal file
173
src/app/api/training-records/[id]/certificates/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
213
src/app/api/training-records/[id]/review/route.ts
Normal file
213
src/app/api/training-records/[id]/review/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
319
src/app/api/training-records/[id]/route.ts
Normal file
319
src/app/api/training-records/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
439
src/app/api/training-records/import/route.ts
Normal file
439
src/app/api/training-records/import/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
56
src/app/api/training-records/import/template/route.ts
Normal file
56
src/app/api/training-records/import/template/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
208
src/app/api/training-records/route.ts
Normal file
208
src/app/api/training-records/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
35
src/app/api/user-permissions/[userId]/assignment/route.ts
Normal file
35
src/app/api/user-permissions/[userId]/assignment/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
27
src/app/api/user-permissions/[userId]/effective/route.ts
Normal file
27
src/app/api/user-permissions/[userId]/effective/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
39
src/app/api/user-permissions/[userId]/overrides/route.ts
Normal file
39
src/app/api/user-permissions/[userId]/overrides/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
src/app/api/user-permissions/[userId]/route.ts
Normal file
31
src/app/api/user-permissions/[userId]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
src/app/api/user-permissions/route.ts
Normal file
34
src/app/api/user-permissions/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
124
src/app/api/users/[id]/route.ts
Normal file
124
src/app/api/users/[id]/route.ts
Normal 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
121
src/app/api/users/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user