diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml index 9a1f304..d2dccee 100644 --- a/public/sitemap-0.xml +++ b/public/sitemap-0.xml @@ -1,33 +1,36 @@ -https://roboticspec.com2026-08-10T09:07:57.626Zdaily0.7 -https://roboticspec.com/activities2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/ActivitiesEditor2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/BlogsEditor2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/BubbleMenu2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/EventsEditor2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/FormatButton2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/HeroEditor2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/MarkdownEditor2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/MenuBar2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/ProjectsEditor2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/ResourceEditor2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/Role2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/SelectSearch2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/TeamEditor2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/role/RoleAccordion2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/role/RolesPanel2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/role/UserRow2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/components/role/UsersPanel2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/admin/page2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/contact2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/events2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/events/components/EventCalendar2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/login2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/login/callback2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/project2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/project/components/ProjectCard2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/resources2026-08-10T09:07:57.627Zdaily0.7 -https://roboticspec.com/resources/components/ResourceCard2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com2026-08-14T11:39:37.785Zdaily0.7 +https://roboticspec.com/activities2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/ActivitiesEditor2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/BlogsEditor2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/BubbleMenu2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/EventsEditor2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/FormatButton2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/HeroEditor2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/MarkdownEditor2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/MenuBar2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/ProjectsEditor2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/ResourceEditor2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/Role2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/SelectSearch2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/TeamEditor2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/role/RoleAccordion2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/role/RolesPanel2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/role/UserRow2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/components/role/UsersPanel2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/admin/page2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/apply2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/contact2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/events2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/events/components/EventCalendar2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/login2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/login/callback2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/panelist2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/panelist/dashboard2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/project2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/project/components/ProjectCard2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/resources2026-08-14T11:39:37.786Zdaily0.7 +https://roboticspec.com/resources/components/ResourceCard2026-08-14T11:39:37.786Zdaily0.7 \ No newline at end of file diff --git a/src/lib/supabase/actions/applicants.actions.ts b/src/lib/supabase/actions/applicants.actions.ts index 67f930c..6af5306 100644 --- a/src/lib/supabase/actions/applicants.actions.ts +++ b/src/lib/supabase/actions/applicants.actions.ts @@ -15,45 +15,71 @@ export const fetchApplicants = async (): Promise => { const { data, error } = await client .from("applicants") .select("*, applicant_response(branch, responses)") - .order("created_at", { ascending: false }); + .order("created_at", { + ascending: false, + }); if (error) { - console.error("Error fetching applicants:", error); + console.error( + "Error fetching applicants:", + error + ); + return []; } return (data as any[]).map((item) => { const response = item.applicant_response; - const responseData = Array.isArray(response) ? response[0] : response; + + const responseData = Array.isArray(response) + ? response[0] + : response; + return { ...item, status: item.status?.toLowerCase(), - createdAt: item.createdAt || item.created_at, + createdAt: + item.createdAt || + item.created_at, branch: responseData?.branch, responses: responseData?.responses, }; }) as ApplicantType[]; }; -export const fetchApplicantWithResponses = async (id: string): Promise => { +export const fetchApplicantWithResponses = async ( + id: string +): Promise => { const { data, error } = await client .from("applicants") - .select("*, applicant_response(branch, responses)") + .select( + "*, applicant_response(branch, responses)" + ) .eq("id", id) .single(); if (error) { - console.error("Error fetching applicant with responses:", error); + console.error( + "Error fetching applicant with responses:", + error + ); + return null; } - const response = (data as any).applicant_response; - const responseData = Array.isArray(response) ? response[0] : response; + const response = + (data as any).applicant_response; + + const responseData = Array.isArray(response) + ? response[0] + : response; return { ...data, status: data.status?.toLowerCase(), - createdAt: data.createdAt || data.created_at, + createdAt: + data.createdAt || + data.created_at, branch: responseData?.branch, responses: responseData?.responses, } as ApplicantType; @@ -79,14 +105,20 @@ export const createWalkIn = async ( .single(); if (error) { - console.error("Error creating walk-in:", error); + console.error( + "Error creating walk-in:", + error + ); + return null; } return { ...data, status: data.status?.toLowerCase(), - createdAt: data.createdAt || data.created_at, + createdAt: + data.createdAt || + data.created_at, } as ApplicantType; }; @@ -96,7 +128,16 @@ export const createApplicant = async ( branch: string, responses: Record ): Promise => { - const { data: applicantData, error: applicantError } = await client + /* + * --------------------------------------------------------- + * 1. Create applicant in Supabase + * --------------------------------------------------------- + */ + + const { + data: applicantData, + error: applicantError, + } = await client .from("applicants") .insert([ { @@ -111,14 +152,22 @@ export const createApplicant = async ( if (applicantError) { if (applicantError.code === "23505") { - console.error("Duplicate application:", applicantError); + console.error( + "Duplicate application:", + applicantError + ); + return { success: false, reason: "duplicate", }; } - console.error("Error creating applicant:", applicantError); + console.error( + "Error creating applicant:", + applicantError + ); + console.error("Error details:", { message: applicantError.message, details: applicantError.details, @@ -132,6 +181,12 @@ export const createApplicant = async ( }; } + /* + * --------------------------------------------------------- + * 2. Store responses in Supabase + * --------------------------------------------------------- + */ + const { error: responseError } = await client .from("applicant_response") .insert([ @@ -143,21 +198,92 @@ export const createApplicant = async ( ]); if (responseError) { - console.error("Error creating applicant response:", responseError); - await client.from("applicants").delete().eq("id", applicantData.id); - + console.error( + "Error creating applicant response:", + responseError + ); + + /* + * Roll back applicant if its responses could not + * be stored. + */ + await client + .from("applicants") + .delete() + .eq("id", applicantData.id); + return { success: false, reason: "error", }; } + /* + * --------------------------------------------------------- + * 3. Sync application to Google Sheets + * --------------------------------------------------------- + * + * Supabase remains the source of truth. + * A Google Sheets failure does NOT invalidate the + * application. + */ + + const { + data: sheetsData, + error: sheetsError, + } = await client.functions.invoke( + "sync-application-to-sheets", + { + body: { + operation: "application", + + applicationId: applicantData.id, + + name, + sid, + branch, + + q1: responses.Q1 || "", + q2: responses.Q2 || "", + q3: responses.Q3 || "", + q4: responses.Q4 || "", + }, + } + ); + + if (sheetsError) { + console.error( + "Application saved to Supabase, but Google Sheets synchronization failed:", + sheetsError + ); + } else if ( + sheetsData?.success === false + ) { + console.error( + "Google Sheets synchronization failed:", + sheetsData + ); + } + + /* + * --------------------------------------------------------- + * 4. Return successful application + * --------------------------------------------------------- + */ + return { success: true, + applicant: { ...applicantData, - status: applicantData.status?.toLowerCase(), - createdAt: applicantData.createdAt || applicantData.created_at, + + status: + applicantData.status?.toLowerCase(), + + createdAt: + applicantData.createdAt || + applicantData.created_at, + branch, responses, } as ApplicantType, @@ -170,27 +296,97 @@ export const updateApplicantDecision = async ( remarks: string, reviewedBy: string ): Promise => { + /* + * Use one timestamp for both Supabase and Google Sheets. + */ + const reviewedAt = new Date().toISOString(); + + /* + * --------------------------------------------------------- + * 1. Update the existing applicant record + * --------------------------------------------------------- + */ + const { error } = await client .from("applicants") .update({ status: status.toUpperCase(), remarks, - reviewedBy: reviewedBy, - reviewedAt: new Date().toISOString(), + reviewedBy, + reviewedAt, }) .eq("id", applicantId); if (error) { - console.error("Error updating applicant decision:", error); + console.error( + "Error updating applicant decision:", + error + ); + return false; } + /* + * --------------------------------------------------------- + * 2. Sync decision to Results Google Sheet + * --------------------------------------------------------- + * + * We send only data already present in the + * existing application architecture. + * + * The Edge Function uses applicantId to obtain + * the applicant's name, SID and branch. + */ + + const { + data: sheetsData, + error: sheetsError, + } = await client.functions.invoke( + "sync-application-to-sheets", + { + body: { + operation: "result", + + applicationId: applicantId, + + status, + remarks, + reviewedBy, + reviewedAt, + }, + } + ); + + /* + * Supabase remains the source of truth. + * Do NOT undo a valid decision because Sheets + * synchronization failed. + */ + + if (sheetsError) { + console.error( + "Decision saved to Supabase, but Results Sheet synchronization failed:", + sheetsError + ); + } else if ( + sheetsData?.success === false + ) { + console.error( + "Results Sheet synchronization failed:", + sheetsData + ); + } + return true; }; export const subscribeToApplicantUpdates = ( - onUpdate: (applicant: ApplicantType) => void, - onInsert: (applicant: ApplicantType) => void + onUpdate: ( + applicant: ApplicantType + ) => void, + onInsert: ( + applicant: ApplicantType + ) => void ) => { const channel = client .channel("applicants-status") @@ -202,11 +398,16 @@ export const subscribeToApplicantUpdates = ( table: "applicants", }, (payload) => { - const item = payload.new as any; + const item = + payload.new as any; + onUpdate({ ...item, - status: item.status?.toLowerCase(), - createdAt: item.createdAt || item.created_at, + status: + item.status?.toLowerCase(), + createdAt: + item.createdAt || + item.created_at, } as ApplicantType); } ) @@ -218,11 +419,16 @@ export const subscribeToApplicantUpdates = ( table: "applicants", }, (payload) => { - const item = payload.new as any; + const item = + payload.new as any; + onInsert({ ...item, - status: item.status?.toLowerCase(), - createdAt: item.createdAt || item.created_at, + status: + item.status?.toLowerCase(), + createdAt: + item.createdAt || + item.created_at, } as ApplicantType); } ) diff --git a/supabase/functions/sync-application-to-sheets/deno.json b/supabase/functions/sync-application-to-sheets/deno.json new file mode 100644 index 0000000..d002339 --- /dev/null +++ b/supabase/functions/sync-application-to-sheets/deno.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "strict": true + } +} \ No newline at end of file diff --git a/supabase/functions/sync-application-to-sheets/index.ts b/supabase/functions/sync-application-to-sheets/index.ts new file mode 100644 index 0000000..2ab70d9 --- /dev/null +++ b/supabase/functions/sync-application-to-sheets/index.ts @@ -0,0 +1,514 @@ +import { createClient } from "npm:@supabase/supabase-js@2"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": + "POST, OPTIONS", +}; + +type ApplicationOperation = { + operation: "application"; + + applicationId: string; + name: string; + sid: string; + branch: string; + q1: string; + q2: string; + q3: string; + q4: string; +}; + +type ResultOperation = { + operation: "result"; + + applicationId: string; + status: "accepted" | "rejected"; + remarks: string; + reviewedBy: string; + reviewedAt: string; +}; + +type RequestData = + | ApplicationOperation + | ResultOperation; + +Deno.serve(async (req: Request) => { + /* + * --------------------------------------------------------- + * CORS preflight + * --------------------------------------------------------- + */ + + if (req.method === "OPTIONS") { + return new Response("ok", { + headers: corsHeaders, + }); + } + + if (req.method !== "POST") { + return new Response( + JSON.stringify({ + success: false, + error: "Method not allowed", + }), + { + status: 405, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + try { + /* + * --------------------------------------------------------- + * 1. Verify authenticated Supabase user + * --------------------------------------------------------- + */ + + const authorization = + req.headers.get("Authorization"); + + if (!authorization) { + return new Response( + JSON.stringify({ + success: false, + error: "Authentication required", + }), + { + status: 401, + headers: { + ...corsHeaders, + "Content-Type": + "application/json", + }, + } + ); + } + + const supabaseUrl = + Deno.env.get("SUPABASE_URL"); + + const supabaseAnonKey = + Deno.env.get("SUPABASE_ANON_KEY"); + + if (!supabaseUrl || !supabaseAnonKey) { + throw new Error( + "Supabase environment variables are missing" + ); + } + + const supabase = createClient( + supabaseUrl, + supabaseAnonKey, + { + global: { + headers: { + Authorization: + authorization, + }, + }, + } + ); + + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + return new Response( + JSON.stringify({ + success: false, + error: + "Invalid or expired Supabase session", + }), + { + status: 401, + headers: { + ...corsHeaders, + "Content-Type": + "application/json", + }, + } + ); + } + + /* + * --------------------------------------------------------- + * 2. Parse request + * --------------------------------------------------------- + */ + + const data = + (await req.json()) as RequestData; + + if (!data.operation) { + return new Response( + JSON.stringify({ + success: false, + error: + "Missing operation", + }), + { + status: 400, + headers: { + ...corsHeaders, + "Content-Type": + "application/json", + }, + } + ); + } + + /* + * --------------------------------------------------------- + * 3. Read Google configuration + * --------------------------------------------------------- + */ + + const googleScriptUrl = + Deno.env.get( + "GOOGLE_SHEETS_SCRIPT_URL" + ); + + const googleSheetsSecret = + Deno.env.get( + "GOOGLE_SHEETS_SECRET" + ); + + if ( + !googleScriptUrl || + !googleSheetsSecret + ) { + throw new Error( + "Google Sheets configuration is missing" + ); + } + + /* + * --------------------------------------------------------- + * 4. APPLICATION OPERATION + * --------------------------------------------------------- + * + * Existing behavior: + * write the application to the current academic-year + * application sheet. + */ + + if (data.operation === "application") { + const application = + data as ApplicationOperation; + + const requiredFields: ( + keyof ApplicationOperation + )[] = [ + "applicationId", + "name", + "sid", + "branch", + "q1", + "q2", + "q3", + "q4", + ]; + + for (const field of requiredFields) { + if ( + application[field] === undefined || + application[field] === null || + String(application[field]) + .trim() === "" + ) { + return new Response( + JSON.stringify({ + success: false, + error: + `Missing required field: ${field}`, + }), + { + status: 400, + headers: { + ...corsHeaders, + "Content-Type": + "application/json", + }, + } + ); + } + } + + const googleResponse = + await fetch( + googleScriptUrl, + { + method: "POST", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + operation: + "application", + + applicationId: + application.applicationId, + + name: + application.name, + + sid: + application.sid, + + branch: + application.branch, + + q1: + application.q1, + + q2: + application.q2, + + q3: + application.q3, + + q4: + application.q4, + + secret: + googleSheetsSecret, + + userId: user.id, + }), + } + ); + + let googleResult: { + success?: boolean; + duplicate?: boolean; + error?: string; + }; + + try { + googleResult = + await googleResponse.json(); + } catch { + throw new Error( + `Google Apps Script returned an invalid response (HTTP ${googleResponse.status})` + ); + } + + if ( + !googleResponse.ok || + !googleResult.success + ) { + throw new Error( + googleResult.error || + `Google Sheets synchronization failed (HTTP ${googleResponse.status})` + ); + } + + return new Response( + JSON.stringify({ + success: true, + operation: "application", + duplicate: + googleResult.duplicate ?? + false, + }), + { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": + "application/json", + }, + } + ); + } + + /* + * --------------------------------------------------------- + * 5. RESULT OPERATION + * --------------------------------------------------------- + * + * We do NOT ask the frontend to send name/SID/branch. + * + * We fetch those from the existing Supabase data using + * the existing application ID. + * + * This avoids duplicating data sources. + */ + + if (data.operation === "result") { + const result = + data as ResultOperation; + + const { data: applicant, error } = + await supabase + .from("applicants") + .select( + "id, name, sid, isWalkin, applicant_response(branch)" + ) + .eq( + "id", + result.applicationId + ) + .single(); + + if (error || !applicant) { + throw new Error( + "Could not fetch applicant for result sheet" + ); + } + + const response = + (applicant as any) + .applicant_response; + + const responseData = + Array.isArray(response) + ? response[0] + : response; + + const branch = + responseData?.branch || ""; + + /* + * Send the existing decision data + existing + * applicant information to Apps Script. + */ + + const googleResponse = + await fetch( + googleScriptUrl, + { + method: "POST", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + operation: "result", + + applicationId: + result.applicationId, + + name: + applicant.name, + + sid: + applicant.sid, + + branch, + + result: + result.status, + + remarks: + result.remarks || "", + + reviewedBy: + result.reviewedBy, + + reviewedAt: + result.reviewedAt, + + secret: + googleSheetsSecret, + + userId: user.id, + }), + } + ); + + let googleResult: { + success?: boolean; + error?: string; + }; + + try { + googleResult = + await googleResponse.json(); + } catch { + throw new Error( + `Google Apps Script returned an invalid response (HTTP ${googleResponse.status})` + ); + } + + if ( + !googleResponse.ok || + !googleResult.success + ) { + throw new Error( + googleResult.error || + `Results Sheet synchronization failed (HTTP ${googleResponse.status})` + ); + } + + return new Response( + JSON.stringify({ + success: true, + operation: "result", + }), + { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": + "application/json", + }, + } + ); + } + + /* + * Unknown operation + */ + + return new Response( + JSON.stringify({ + success: false, + error: + "Unsupported operation", + }), + { + status: 400, + headers: { + ...corsHeaders, + "Content-Type": + "application/json", + }, + } + ); + + } catch (error) { + console.error( + "sync-application-to-sheets error:", + error + ); + + return new Response( + JSON.stringify({ + success: false, + error: + error instanceof Error + ? error.message + : "Unknown error", + }), + { + status: 500, + headers: { + ...corsHeaders, + "Content-Type": + "application/json", + }, + } + ); + } +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e4918ec..10c0559 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next-sitemap.config.js"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "supabase"] }