summaryrefslogtreecommitdiff
path: root/lib/vendor-candidates/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-candidates/service.ts')
-rw-r--r--lib/vendor-candidates/service.ts360
1 files changed, 360 insertions, 0 deletions
diff --git a/lib/vendor-candidates/service.ts b/lib/vendor-candidates/service.ts
new file mode 100644
index 00000000..68971f18
--- /dev/null
+++ b/lib/vendor-candidates/service.ts
@@ -0,0 +1,360 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { vendorCandidates} from "@/db/schema/vendors"
+import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm";
+import { revalidateTag, unstable_noStore } from "next/cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+import db from "@/db/db";
+import { sendEmail } from "../mail/sendEmail";
+import { CreateVendorCandidateSchema, createVendorCandidateSchema, GetVendorsCandidateSchema, RemoveCandidatesInput, removeCandidatesSchema, updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "./validations";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+export async function getVendorCandidates(input: GetVendorsCandidateSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 1) Advanced filters
+ const advancedWhere = filterColumns({
+ table: vendorCandidates,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ })
+
+ // 2) Global search
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(vendorCandidates.companyName, s),
+ ilike(vendorCandidates.contactEmail, s),
+ ilike(vendorCandidates.contactPhone, s),
+ ilike(vendorCandidates.country, s),
+ ilike(vendorCandidates.source, s),
+ ilike(vendorCandidates.status, s),
+ // etc.
+ )
+ }
+
+ // 3) Combine finalWhere
+ // Example: Only show vendorStatus = "PQ_SUBMITTED"
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere,
+ )
+
+
+
+ // 5) Sorting
+ const orderBy =
+ input.sort && input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(vendorCandidates[item.id])
+ : asc(vendorCandidates[item.id])
+ )
+ : [desc(vendorCandidates.createdAt)]
+
+ // 6) Query & count
+ const { data, total } = await db.transaction(async (tx) => {
+ // a) Select from the view
+ const candidatesData = await tx
+ .select()
+ .from(vendorCandidates)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(input.perPage)
+
+ // b) Count total
+ const resCount = await tx
+ .select({ count: count() })
+ .from(vendorCandidates)
+ .where(finalWhere)
+
+ return { data: candidatesData, total: resCount[0]?.count }
+ })
+
+ // 7) Calculate pageCount
+ const pageCount = Math.ceil(total / input.perPage)
+
+ // Now 'data' already contains JSON arrays of contacts & items
+ // thanks to the subqueries in the view definition!
+ return { data, pageCount }
+ } catch (err) {
+ console.error(err)
+ return { data: [], pageCount: 0 }
+ }
+ },
+ // Cache key
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: ["vendor-candidates"],
+ }
+ )()
+}
+
+export async function createVendorCandidate(input: CreateVendorCandidateSchema) {
+ try {
+ // Validate input
+ const validated = createVendorCandidateSchema.parse(input);
+
+ // Insert into database
+ const [newCandidate] = await db
+ .insert(vendorCandidates)
+ .values({
+ companyName: validated.companyName,
+ contactEmail: validated.contactEmail,
+ contactPhone: validated.contactPhone || null,
+ country: validated.country || null,
+ source: validated.source || null,
+ status: validated.status,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ // Invalidate cache
+ revalidateTag("vendor-candidates");
+
+ return { success: true, data: newCandidate };
+ } catch (error) {
+ console.error("Failed to create vendor candidate:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+
+// Helper function to group vendor candidates by status
+async function groupVendorCandidatesByStatus( tx: PgTransaction<any, any, any>,) {
+ return tx
+ .select({
+ status: vendorCandidates.status,
+ count: count(),
+ })
+ .from(vendorCandidates)
+ .groupBy(vendorCandidates.status);
+}
+
+/**
+ * Get count of vendor candidates grouped by status
+ */
+export async function getVendorCandidateCounts() {
+ return unstable_cache(
+ async () => {
+ try {
+ // Initialize counts object with all possible statuses set to 0
+ const initial: Record<"COLLECTED" | "INVITED" | "DISCARDED", number> = {
+ COLLECTED: 0,
+ INVITED: 0,
+ DISCARDED: 0,
+ };
+
+ // Execute query within transaction and transform results
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupVendorCandidatesByStatus(tx);
+ return rows.reduce<Record<string, number>>((acc, { status, count }) => {
+ if (status in acc) {
+ acc[status] = count;
+ }
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ console.error("Failed to get vendor candidate counts:", err);
+ return {
+ COLLECTED: 0,
+ INVITED: 0,
+ DISCARDED: 0,
+ };
+ }
+ },
+ ["vendor-candidate-status-counts"], // Cache key
+ {
+ revalidate: 3600, // Revalidate every hour
+ // tags: ["vendor-candidates"], // Use the same tag as other vendor candidate functions
+ }
+ )();
+}
+
+
+/**
+ * Update a vendor candidate
+ */
+export async function updateVendorCandidate(input: UpdateVendorCandidateSchema) {
+ try {
+ // Validate input
+ const validated = updateVendorCandidateSchema.parse(input);
+
+ // Prepare update data (excluding id)
+ const { id, ...updateData } = validated;
+
+ // Add updatedAt timestamp
+ const dataToUpdate = {
+ ...updateData,
+ updatedAt: new Date(),
+ };
+
+ // Update database
+ const [updatedCandidate] = await db
+ .update(vendorCandidates)
+ .set(dataToUpdate)
+ .where(eq(vendorCandidates.id, id))
+ .returning();
+
+ // If status was updated to "INVITED", send email
+ if (validated.status === "INVITED" && updatedCandidate.contactEmail) {
+ await sendEmail({
+ to: updatedCandidate.contactEmail,
+ subject: "Invitation to Register as a Vendor",
+ template: "vendor-invitation",
+ context: {
+ companyName: updatedCandidate.companyName,
+ language: "en",
+ registrationLink: `${process.env.NEXT_PUBLIC_APP_URL}/en/partners`,
+ }
+ });
+ }
+
+ // Invalidate cache
+ revalidateTag("vendor-candidates");
+
+ return { success: true, data: updatedCandidate };
+ } catch (error) {
+ console.error("Failed to update vendor candidate:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * Update status of multiple vendor candidates at once
+ */
+export async function bulkUpdateVendorCandidateStatus({
+ ids,
+ status
+}: {
+ ids: number[],
+ status: "COLLECTED" | "INVITED" | "DISCARDED"
+}) {
+ try {
+ // Validate inputs
+ if (!ids.length) {
+ return { success: false, error: "No IDs provided" };
+ }
+
+ if (!["COLLECTED", "INVITED", "DISCARDED"].includes(status)) {
+ return { success: false, error: "Invalid status" };
+ }
+
+ // Get current data of candidates (needed for email sending)
+ const candidatesBeforeUpdate = await db
+ .select()
+ .from(vendorCandidates)
+ .where(inArray(vendorCandidates.id, ids));
+
+ // Update all records
+ const updatedCandidates = await db
+ .update(vendorCandidates)
+ .set({
+ status,
+ updatedAt: new Date(),
+ })
+ .where(inArray(vendorCandidates.id, ids))
+ .returning();
+
+ // If status is "INVITED", send emails to all updated candidates
+ if (status === "INVITED") {
+ const emailPromises = updatedCandidates
+ .filter(candidate => candidate.contactEmail)
+ .map(candidate =>
+ sendEmail({
+ to: candidate.contactEmail!,
+ subject: "Invitation to Register as a Vendor",
+ template: "vendor-invitation",
+ context: {
+ companyName: candidate.companyName,
+ language: "en",
+ registrationLink: `${process.env.NEXT_PUBLIC_APP_URL}/en/partners`,
+ }
+ })
+ );
+
+ // Wait for all emails to be sent
+ await Promise.all(emailPromises);
+ }
+
+ // Invalidate cache
+ revalidateTag("vendor-candidates");
+
+ return {
+ success: true,
+ data: updatedCandidates,
+ count: updatedCandidates.length
+ };
+ } catch (error) {
+ console.error("Failed to bulk update vendor candidates:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+
+
+
+/**
+ * Remove multiple vendor candidates by their IDs
+ */
+export async function removeCandidates(input: RemoveCandidatesInput) {
+ try {
+ // Validate input
+ const validated = removeCandidatesSchema.parse(input);
+
+ // Get candidates before deletion (for logging purposes)
+ const candidatesBeforeDelete = await db
+ .select({
+ id: vendorCandidates.id,
+ companyName: vendorCandidates.companyName,
+ })
+ .from(vendorCandidates)
+ .where(inArray(vendorCandidates.id, validated.ids));
+
+ // Delete the candidates
+ const deletedCandidates = await db
+ .delete(vendorCandidates)
+ .where(inArray(vendorCandidates.id, validated.ids))
+ .returning({ id: vendorCandidates.id });
+
+ // If no candidates were deleted, return an error
+ if (!deletedCandidates.length) {
+ return {
+ success: false,
+ error: "No candidates were found with the provided IDs",
+ };
+ }
+
+ // Log deletion for audit purposes
+ console.log(
+ `Deleted ${deletedCandidates.length} vendor candidates:`,
+ candidatesBeforeDelete.map(c => `${c.id} (${c.companyName})`)
+ );
+
+ // Invalidate cache
+ revalidateTag("vendor-candidates");
+ revalidateTag("vendor-candidate-status-counts");
+ revalidateTag("vendor-candidate-total-count");
+
+ return {
+ success: true,
+ count: deletedCandidates.length,
+ deletedIds: deletedCandidates.map(c => c.id),
+ };
+ } catch (error) {
+ console.error("Failed to remove vendor candidates:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+} \ No newline at end of file