untested pushhhhh

This commit is contained in:
Om Lanke
2025-07-07 02:04:10 +05:30
parent acde34a52b
commit 2aeecc9050
9 changed files with 350 additions and 186 deletions

View File

@@ -1,116 +1,114 @@
'use server'
import { db, students, grades, internships as internshipsTable, resumes as resumesTable } from '@workspace/db';
'use server';
import {
db,
students,
grades,
internships as internshipsTable,
resumes as resumesTable,
} from '@workspace/db';
import { eq } from '@workspace/db/drizzle';
import { studentSignupSchema } from './schema';
import { studentSignupSchema, StudentSignup } from './schema';
import { auth } from '@/auth';
export async function signupAction(data: FormData) {
export async function signupAction(data: StudentSignup) {
try {
const session = await auth();
const studentId = session?.user?.studentId;
if (!studentId) {
return { error: 'Student ID not found in session.' };
return { error: 'Student ID not found in session.' };
}
const formData = Object.fromEntries(data.entries());
// Parse arrays/objects from formData if sent as JSON strings
if (typeof formData.skills === 'string') formData.skills = JSON.parse(formData.skills);
if (typeof formData.sgpi === 'string') formData.sgpi = JSON.parse(formData.sgpi);
if (typeof formData.internships === 'string') formData.internships = JSON.parse(formData.internships);
if (typeof formData.resume === 'string') formData.resume = JSON.parse(formData.resume);
const parsedData = await studentSignupSchema.safeParseAsync(formData);
// Validate data using schema
const parsedData = await studentSignupSchema.safeParseAsync(data);
if (!parsedData.success) {
return { error: parsedData.error.issues };
return { error: parsedData.error.issues };
}
const student = parsedData.data;
// Update student table
await db.update(students).set({
rollNumber: student.rollNumber,
firstName: student.firstName,
middleName: student.middleName,
lastName: student.lastName,
mothersName: student.mothersName,
gender: student.gender,
dob: student.dob,
personalGmail: student.personalGmail,
phoneNumber: student.phoneNumber,
address: student.address,
degree: student.degree,
branch: student.branch,
year: student.year,
skills: student.skills, // store as array
linkedin: student.linkedin,
github: student.github,
ssc: String(student.ssc),
hsc: String(student.hsc),
isDiploma: student.isDiploma,
}).where(eq(students.id, studentId));
// Use a transaction to ensure all operations succeed or fail together
await db.transaction(async (tx) => {
// Update student table
await tx
.update(students)
.set({
rollNumber: student.rollNumber,
firstName: student.firstName,
middleName: student.middleName,
lastName: student.lastName,
mothersName: student.mothersName,
gender: student.gender,
dob: student.dob,
personalGmail: student.personalGmail,
phoneNumber: student.phoneNumber,
address: student.address,
degree: student.degree,
branch: student.branch,
year: student.year,
skills: student.skills, // store as array
linkedin: student.linkedin,
github: student.github,
ssc: String(student.ssc),
hsc: String(student.hsc),
isDiploma: student.isDiploma,
})
.where(eq(students.id, studentId));
// Upsert grades (sgpi)
if (Array.isArray(student.sgpi)) {
// Clear existing grades for this student
await tx.delete(grades).where(eq(grades.studentId, studentId));
// Insert grades (sgpi)
if (Array.isArray(student.sgpi)) {
for (const grade of student.sgpi) {
await db.insert(grades).values({
studentId: studentId,
sem: grade.sem,
sgpi: String(grade.sgpi),
isKT: grade.kt,
deadKT: grade.ktDead,
}).onConflictDoUpdate({
target: [grades.studentId, grades.sem],
set: {
sgpi: String(grade.sgpi),
isKT: grade.kt,
deadKT: grade.ktDead,
updatedAt: new Date(),
},
});
await tx.insert(grades).values({
studentId: studentId,
sem: grade.sem,
sgpi: String(grade.sgpi),
isKT: grade.kt,
deadKT: grade.ktDead,
});
}
}
}
// Upsert internships
if (Array.isArray(student.internships)) {
// Clear existing internships for this student
await tx.delete(internshipsTable).where(eq(internshipsTable.studentId, studentId));
// Insert internships
if (Array.isArray(student.internships)) {
for (const internship of student.internships) {
await db.insert(internshipsTable).values({
studentId,
title: internship.title,
company: internship.company,
description: internship.description,
location: internship.location,
startDate: internship.startDate,
endDate: internship.endDate,
}).onConflictDoUpdate({
target: [internshipsTable.studentId, internshipsTable.title, internshipsTable.company],
set: {
description: internship.description,
location: internship.location,
startDate: internship.startDate,
endDate: internship.endDate,
updatedAt: new Date(),
},
});
await tx.insert(internshipsTable).values({
studentId,
title: internship.title,
company: internship.company,
description: internship.description,
location: internship.location,
startDate: internship.startDate,
endDate: internship.endDate,
});
}
}
}
// Upsert resumes
if (Array.isArray(student.resume)) {
// Clear existing resumes for this student
await tx.delete(resumesTable).where(eq(resumesTable.studentId, studentId));
// Insert resumes
if (Array.isArray(student.resume)) {
for (const resume of student.resume) {
await db.insert(resumesTable).values({
studentId,
title: resume.title,
link: resume.link,
}).onConflictDoUpdate({
target: [resumesTable.studentId, resumesTable.title],
set: {
link: resume.link,
updatedAt: new Date(),
},
});
await tx.insert(resumesTable).values({
studentId,
title: resume.title,
link: resume.link,
});
}
}
}
});
return { success: true };
} catch (error) {
console.error('Signup action error:', error);
return {
error: error instanceof Error ? error.message : 'An unexpected error occurred during signup.',
};
}
}

View File

@@ -4,7 +4,7 @@
'use client';
import { useState } from 'react';
import { useState, useTransition } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -13,6 +13,7 @@ import { Button } from '@workspace/ui/components/button';
import { Progress } from '@workspace/ui/components/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Form } from '@workspace/ui/components/form';
import { useRouter } from 'next/navigation';
import { studentSignupSchema, StudentSignup } from './schema';
import PersonalDetailsStep from './steps/PersonalDetailsStep';
@@ -22,9 +23,29 @@ import AdditionalDetailsStep from './steps/AdditionalDetailsStep';
import InternshipStep from './steps/InternshipStep';
import ResumeStep from './steps/ResumeStep';
import { signupAction } from './action';
const steps = [
{ id: 1, title: 'Personal Details', fields: ['firstName', 'lastName', 'mothersName', 'rollNumber', 'phoneNumber', 'address', 'gender', 'dob', 'personalGmail'] },
{ id: 2, title: 'Academic Details', fields: ['degree', 'year', 'branch', 'ssc', 'hsc', 'isDiploma'] },
{
id: 1,
title: 'Personal Details',
fields: [
'firstName',
'lastName',
'mothersName',
'rollNumber',
'phoneNumber',
'address',
'gender',
'dob',
'personalGmail',
],
},
{
id: 2,
title: 'Academic Details',
fields: ['degree', 'year', 'branch', 'ssc', 'hsc', 'isDiploma'],
},
{ id: 3, title: 'Semester Grades', fields: ['sgpi'] },
{ id: 4, title: 'Additional Details', fields: ['linkedin', 'github', 'skills'] },
{ id: 5, title: 'Internships', fields: ['internships'] },
@@ -34,6 +55,8 @@ const steps = [
export default function StudentRegistrationForm() {
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const form = useForm<StudentSignup>({
resolver: zodResolver(studentSignupSchema),
@@ -57,7 +80,12 @@ export default function StudentRegistrationForm() {
ssc: 0,
hsc: 0,
isDiploma: false,
sgpi: Array.from({ length: 8 }, (_, i) => ({ sem: i + 1, sgpi: 0, kt: false, ktDead: false })),
sgpi: Array.from({ length: 8 }, (_, i) => ({
sem: i + 1,
sgpi: 0,
kt: false,
ktDead: false,
})),
internships: [],
resume: [],
},
@@ -68,13 +96,21 @@ export default function StudentRegistrationForm() {
const validateCurrentStep = async () => {
const current = steps.find((s) => s.id === currentStep);
if (!current) return false;
// Cast fields to the correct type for react-hook-form
return await form.trigger(current.fields as Parameters<typeof form.trigger>[0]);
try {
const result = await form.trigger(current.fields as (keyof StudentSignup)[]);
return result;
} catch (error) {
console.error('Validation error:', error);
return false;
}
};
const nextStep = async () => {
const isValid = await validateCurrentStep();
if (isValid && currentStep < steps.length) setCurrentStep((prev) => prev + 1);
if (isValid && currentStep < steps.length) {
setCurrentStep((prev) => prev + 1);
}
};
const prevStep = () => {
@@ -84,13 +120,24 @@ export default function StudentRegistrationForm() {
const onSubmit = async (data: StudentSignup) => {
// Only submit if on the last step
if (currentStep !== steps.length) return;
setIsSubmitting(true);
try {
await new Promise((res) => setTimeout(res, 2000));
console.log('Form submitted:', data);
alert('Form submitted successfully!');
const result = await signupAction(data);
if (result && result.success) {
router.push('/');
return;
}
if (result && result.error) {
const errorMessage = Array.isArray(result.error)
? result.error.map((e) => e.message || e).join(', ')
: result.error;
alert('Submission failed: ' + errorMessage);
} else {
alert('Submission failed. Try again.');
}
} catch (err) {
console.error(err);
console.error('Submission error:', err);
alert('Submission failed. Try again.');
} finally {
setIsSubmitting(false);
@@ -128,7 +175,9 @@ export default function StudentRegistrationForm() {
</CardTitle>
<div className="space-y-2">
<div className="flex justify-between text-sm text-muted-foreground">
<span>Step {currentStep} of {steps.length}</span>
<span>
Step {currentStep} of {steps.length}
</span>
<span>{steps[currentStep - 1]?.title}</span>
</div>
<Progress value={progress} className="w-full" />
@@ -139,7 +188,7 @@ export default function StudentRegistrationForm() {
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
onKeyDown={e => {
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
e.target instanceof HTMLElement &&
@@ -151,16 +200,17 @@ export default function StudentRegistrationForm() {
>
{renderStep()}
<div className="flex justify-between pt-6">
<Button type="button" variant="outline" onClick={prevStep} disabled={currentStep === 1}>
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={currentStep === 1}
>
Previous
</Button>
{currentStep === steps.length ? (
<Button
type="button"
disabled={isSubmitting}
onClick={() => form.handleSubmit(onSubmit)()}
>
{isSubmitting ? 'Submitting...' : 'Submit'}
<Button type="submit" disabled={isSubmitting || isPending}>
{isSubmitting || isPending ? 'Submitting...' : 'Submit'}
</Button>
) : (
<Button type="button" onClick={nextStep}>

View File

@@ -7,43 +7,50 @@ export const sgpiSchema = z.object({
ktDead: z.boolean(),
});
export const internshipSchema = z.object({
title: z.string(),
company: z.string(),
description: z.string(),
location: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
});
export const internshipSchema = z
.object({
title: z.string().min(1, 'Title is required'),
company: z.string().min(1, 'Company is required'),
description: z.string().min(1, 'Description is required'),
location: z.string().min(1, 'Location is required'),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
})
.refine((data) => data.endDate >= data.startDate, {
message: 'End date must be after start date',
path: ['endDate'],
});
export const resumeSchema = z.object({
title: z.string(),
link: z.string().url(),
title: z.string().min(1, 'Title is required'),
link: z.string().url('Must be a valid URL'),
});
export const studentSignupSchema = z.object({
rollNumber: z.string().max(12),
firstName: z.string().max(255),
middleName: z.string().max(255),
lastName: z.string().max(255),
mothersName: z.string().max(255),
gender: z.string().max(10),
dob: z.coerce.date(),
personalGmail: z.string().email(),
phoneNumber: z.string().max(10),
address: z.string(),
degree: z.string(),
branch: z.string(),
year: z.string(),
rollNumber: z.string().min(1, 'Roll number is required').max(12),
firstName: z.string().min(1, 'First name is required').max(255),
middleName: z.string().max(255).optional(),
lastName: z.string().min(1, 'Last name is required').max(255),
mothersName: z.string().min(1, "Mother's name is required").max(255),
gender: z.string().min(1, 'Gender is required').max(10),
dob: z.coerce.date().refine((date) => date <= new Date(), {
message: 'Date of birth cannot be in the future',
}),
personalGmail: z.string().email('Must be a valid email'),
phoneNumber: z.string().min(10, 'Phone number must be at least 10 digits').max(10),
address: z.string().min(1, 'Address is required'),
degree: z.string().min(1, 'Degree is required'),
branch: z.string().min(1, 'Branch is required'),
year: z.string().min(1, 'Year is required'),
skills: z.array(z.string()),
linkedin: z.string(),
github: z.string(),
ssc: z.coerce.number(),
hsc: z.coerce.number(),
ssc: z.coerce.number().min(0).max(100),
hsc: z.coerce.number().min(0).max(100),
isDiploma: z.boolean(),
sgpi: z.array(sgpiSchema),
internships: z.array(internshipSchema).optional(),
resume: z.array(resumeSchema).optional(),
sgpi: z.array(sgpiSchema).length(8, 'Must provide grades for all 8 semesters'),
internships: z.array(internshipSchema),
resume: z.array(resumeSchema),
});
export type StudentSignup = z.infer<typeof studentSignupSchema>;

View File

@@ -39,7 +39,7 @@ export default function AcademicDetailsStep({ form }: { form: any }) {
render={({ field }) => (
<FormItem>
<FormLabel>Degree *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select your degree" />
@@ -64,7 +64,7 @@ export default function AcademicDetailsStep({ form }: { form: any }) {
render={({ field }) => (
<FormItem>
<FormLabel>Year *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Year of graduation" />
@@ -89,7 +89,7 @@ export default function AcademicDetailsStep({ form }: { form: any }) {
render={({ field }) => (
<FormItem>
<FormLabel>Branch *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select your branch" />
@@ -119,7 +119,12 @@ export default function AcademicDetailsStep({ form }: { form: any }) {
<FormItem>
<FormLabel>SSC % *</FormLabel>
<FormControl>
<Input type="number" placeholder="10th percentage" {...field} />
<Input
type="number"
placeholder="10th percentage"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -133,7 +138,12 @@ export default function AcademicDetailsStep({ form }: { form: any }) {
<FormItem>
<FormLabel>HSC % *</FormLabel>
<FormControl>
<Input type="number" placeholder="12th percentage" {...field} />
<Input
type="number"
placeholder="12th percentage"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -146,7 +156,11 @@ export default function AcademicDetailsStep({ form }: { form: any }) {
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<input type="checkbox" checked={field.value} onChange={(e) => field.onChange(e.target.checked)} />
<input
type="checkbox"
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
</FormControl>
<FormLabel className="!m-0">Diploma Holder?</FormLabel>
</FormItem>

View File

@@ -23,10 +23,11 @@ export default function AdditionalDetailsStep({ form }: { form: any }) {
name="linkedin"
render={({ field }) => (
<FormItem>
<FormLabel>LinkedIn Profile *</FormLabel>
<FormLabel>LinkedIn Profile</FormLabel>
<FormControl>
<Input type="url" placeholder="https://linkedin.com/in/yourprofile" {...field} />
</FormControl>
<FormDescription>Optional</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -37,10 +38,11 @@ export default function AdditionalDetailsStep({ form }: { form: any }) {
name="github"
render={({ field }) => (
<FormItem>
<FormLabel>GitHub Profile *</FormLabel>
<FormLabel>GitHub Profile</FormLabel>
<FormControl>
<Input type="url" placeholder="https://github.com/yourusername" {...field} />
</FormControl>
<FormDescription>Optional</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -57,10 +59,20 @@ export default function AdditionalDetailsStep({ form }: { form: any }) {
<Textarea
placeholder="JavaScript, React, Node.js, Python"
className="resize-none"
value={field.value ? (Array.isArray(field.value) ? field.value.join(", ") : field.value) : ""}
onChange={e => {
value={
field.value
? Array.isArray(field.value)
? field.value.join(', ')
: field.value
: ''
}
onChange={(e) => {
const value = e.target.value;
field.onChange(value.split(',').map(s => s.trim()).filter(Boolean));
const skills = value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
field.onChange(skills);
}}
/>
</FormControl>

View File

@@ -12,6 +12,19 @@ import {
import { Input } from '@workspace/ui/components/input';
import { Textarea } from '@workspace/ui/components/textarea';
import { Separator } from '@workspace/ui/components/separator';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@workspace/ui/components/select';
import { Calendar } from '@workspace/ui/components/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@workspace/ui/components/popover';
import { Button } from '@workspace/ui/components/button';
import { CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import { cn } from '@workspace/ui/lib/utils';
export default function PersonalDetailsStep({ form }: { form: any }) {
return (
@@ -76,6 +89,66 @@ export default function PersonalDetailsStep({ form }: { form: any }) {
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="gender"
render={({ field }) => (
<FormItem>
<FormLabel>Gender *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select your gender" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="male">Male</SelectItem>
<SelectItem value="female">Female</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dob"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Date of Birth *</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
'w-full pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
{field.value ? format(field.value, 'PPP') : <span>Pick a date</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) => date > new Date() || date < new Date('1900-01-01')}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -19,7 +19,7 @@ declare module 'next-auth' {
role?: 'ADMIN' | 'USER';
adminId?: number;
studentId?: number;
completedProfile?: boolean;
// completedProfile?: boolean; // Removed from JWT
}
}
@@ -33,41 +33,43 @@ const authConfig: NextAuthConfig = {
providers: [Google],
callbacks: {
async jwt({ token, account, user }) {
// Only check DB on first sign in
if (account && user && user.email) {
const admin = await db.select().from(admins).where(eq(admins.email, user.email)).limit(1);
if (admin.length > 0 && admin[0]) {
token.role = 'ADMIN';
token.adminId = admin[0].id;
token.completedProfile = true;
} else {
// Only set role, adminId, studentId, and email in JWT
const email = user?.email || token?.email;
if (!email) return token;
const admin = await db.select().from(admins).where(eq(admins.email, email)).limit(1);
if (admin.length > 0 && admin[0]) {
token.role = 'ADMIN';
token.adminId = admin[0].id;
token.email = email;
return token;
}
let student = await db.select().from(students).where(eq(students.email, email)).limit(1);
if (student.length > 0 && student[0]) {
token.role = 'USER';
token.studentId = student[0].id;
token.email = email;
return token;
}
if (user) {
const nameParts = user.name?.split(' ') ?? [];
const firstName = nameParts[0] || '';
const lastName = nameParts.slice(1).join(' ') || '';
const newStudent = await db
.insert(students)
.values({
email: email,
firstName: firstName,
lastName: lastName,
profilePicture: user.image,
})
.returning({ id: students.id });
if (newStudent[0]) {
token.role = 'USER';
const student = await db
.select()
.from(students)
.where(eq(students.email, user.email))
.limit(1);
if (student.length > 0 && student[0]) {
token.studentId = student[0].id;
token.completedProfile = student[0].rollNumber ? true : false;
} else {
const nameParts = user.name?.split(' ') ?? [];
const firstName = nameParts[0] || '';
const lastName = nameParts.slice(1).join(' ') || '';
const newStudent = await db
.insert(students)
.values({
email: user.email,
firstName: firstName,
lastName: lastName,
profilePicture: user.image,
})
.returning({ id: students.id });
if (newStudent[0]) {
token.studentId = newStudent[0].id;
token.completedProfile = false;
}
}
token.studentId = newStudent[0].id;
token.email = email;
}
}
return token;
@@ -82,8 +84,12 @@ const authConfig: NextAuthConfig = {
if (token?.studentId) {
session.user.studentId = token.studentId as number;
}
if (token?.completedProfile !== undefined) {
session.user.completedProfile = token.completedProfile as boolean;
// Fetch completedProfile from DB for students only
if (token?.role === 'USER' && token?.studentId) {
const student = await db.select().from(students).where(eq(students.id, token.studentId as number)).limit(1);
session.user.completedProfile = student[0]?.rollNumber ? true : false;
} else if (token?.role === 'ADMIN') {
session.user.completedProfile = true;
}
return session;
},

View File

@@ -16,6 +16,7 @@
"@tanstack/react-table": "^8.21.3",
"@workspace/db": "workspace:*",
"@workspace/ui": "workspace:*",
"date-fns": "^4.1.0",
"framer-motion": "^12.22.0",
"lucide-react": "^0.475.0",
"next": "^15.3.4",

3
pnpm-lock.yaml generated
View File

@@ -126,6 +126,9 @@ importers:
'@workspace/ui':
specifier: workspace:*
version: link:../../packages/ui
date-fns:
specifier: ^4.1.0
version: 4.1.0
framer-motion:
specifier: ^12.22.0
version: 12.22.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)