├── .nvmrc ├── public ├── HelmetLogoFull.png ├── CreateOrgLoading.gif ├── Helmet Logomark.svg ├── example-candidates.csv ├── Helmet Logotype.svg ├── No_Archive.svg ├── Winston Logotype.svg ├── Helmet Logo Full.svg ├── Winston Logomark.svg └── User_empty.svg ├── src ├── lib │ ├── utils │ │ ├── utils.ts │ │ ├── cn.utils.ts │ │ ├── auth.utils.ts │ │ ├── csv.utils.ts │ │ ├── status.utils.ts │ │ └── errors.utils.ts │ ├── fonts │ │ ├── Satoshi-Variable.woff2 │ │ └── Satoshi-VariableItalic.woff2 │ ├── types │ │ ├── member.types.ts │ │ ├── auth.types.ts │ │ └── position.types.ts │ ├── schemas │ │ ├── role.schema.ts │ │ ├── task.schema.ts │ │ ├── upload.schema.ts │ │ ├── organization.schema.ts │ │ ├── assessment.schema.ts │ │ ├── assessment-template.schema.ts │ │ ├── task-template.schema.ts │ │ ├── user.schema.ts │ │ ├── position.schema.ts │ │ ├── candidate.schema.ts │ │ └── candidate-pool.schema.ts │ ├── prisma.ts │ ├── components │ │ ├── Skeleton.tsx │ │ ├── Search.tsx │ │ ├── Separator.tsx │ │ ├── Chip.tsx │ │ ├── Input.tsx │ │ ├── TopNav.tsx │ │ ├── Sonner.tsx │ │ ├── Button.tsx │ │ ├── Field.tsx │ │ ├── Tooltip.tsx │ │ ├── Resizable.tsx │ │ ├── DataTable.tsx │ │ ├── Tabs.tsx │ │ ├── AvatarGroup.tsx │ │ └── Markdown.tsx │ ├── hooks │ │ ├── useIsMobileShadcn.ts │ │ ├── useFileClient.ts │ │ ├── useOnboardingModal.ts │ │ ├── useOnboardingState.ts │ │ ├── useOrganizationCreation.ts │ │ ├── useFileUpload.ts │ │ └── useUploadCSV.tsx │ ├── auth │ │ ├── auth-client.tsx │ │ ├── auth.ts │ │ └── permissions.ts │ ├── connectors │ │ ├── secrets.connector.ts │ │ ├── s3.connector.ts │ │ └── judge0.connector.ts │ └── services │ │ ├── user.service.ts │ │ ├── member.service.ts │ │ ├── task.service.ts │ │ ├── candidate.service.ts │ │ ├── assessment-template.service.ts │ │ ├── assessment.service.ts │ │ ├── task-template.service.ts │ │ └── organization.service.ts ├── app │ ├── api │ │ ├── auth │ │ │ └── [...all] │ │ │ │ └── route.ts │ │ ├── organizations │ │ │ ├── [id] │ │ │ │ ├── positions │ │ │ │ │ └── route.ts │ │ │ │ ├── members │ │ │ │ │ └── [memberId] │ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── tasks │ │ │ ├── route.ts │ │ │ └── [id] │ │ │ │ └── route.ts │ │ ├── assessments │ │ │ ├── route.ts │ │ │ └── [id] │ │ │ │ ├── status │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ ├── task-templates │ │ │ ├── route.ts │ │ │ └── [id] │ │ │ │ └── route.ts │ │ ├── candidates │ │ │ ├── route.ts │ │ │ └── [id] │ │ │ │ └── route.ts │ │ ├── judge │ │ │ └── route.ts │ │ ├── assessment-templates │ │ │ ├── route.ts │ │ │ └── [id] │ │ │ │ └── route.ts │ │ ├── position │ │ │ └── [id] │ │ │ │ ├── candidates │ │ │ │ ├── csv │ │ │ │ │ └── route.ts │ │ │ │ ├── [candidateId] │ │ │ │ │ └── route.ts │ │ │ │ ├── batch │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ ├── users │ │ │ └── [id] │ │ │ │ └── route.ts │ │ └── upload │ │ │ ├── sign │ │ │ └── route.ts │ │ │ └── confirm │ │ │ └── route.ts │ ├── (web) │ │ ├── (crm) │ │ │ ├── positions │ │ │ │ ├── page.tsx │ │ │ │ └── [id] │ │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── dashboard │ │ │ │ └── page.tsx │ │ └── (oa) │ │ │ └── assessment │ │ │ └── [assessmentId] │ │ │ ├── outro │ │ │ └── page.tsx │ │ │ └── intro │ │ │ └── page.tsx │ ├── page.tsx │ ├── actions │ │ └── position.actions.ts │ └── layout.tsx └── middleware.ts ├── postcss.config.mjs ├── prisma ├── migrations │ ├── 20251125164251_test │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20251109164334_remove_limit_problem_description │ │ └── migration.sql │ ├── 20251130194520_add_task_templates_to_assessment_template │ │ └── migration.sql │ ├── 20251104012828_make_user_password_optional │ │ └── migration.sql │ ├── 20251104013359_change_email_verified_to_boolean │ │ └── migration.sql │ ├── 20251130180956_add_candidate_fields_and_status │ │ └── migration.sql │ ├── 20251110012155_sync_candidate_pool_entry_status_and_tags │ │ └── migration.sql │ ├── 20251108213442_test_case_json_list │ │ └── migration.sql │ ├── 20251110155714_add_tags_to_org_level │ │ └── migration.sql │ └── 20251117042814_add_candidate_fields_and_status │ │ └── migration.sql ├── seed-data │ ├── organizations.seed.ts │ ├── positions.seed.ts │ ├── users.seed.ts │ ├── assessment.seed.ts │ ├── assessment-template.seed.ts │ └── candidates.seed.ts └── teardown.ts ├── .dockerignore ├── docker-entrypoint.sh ├── start.sh ├── next.config.ts ├── .prettierignore ├── .prettierrc ├── tailwind.config.ts ├── README.md ├── .gitignore ├── tsconfig.json ├── docker-compose.yaml ├── .github ├── workflows │ ├── prettier-check.yml │ ├── lint-check.yml │ └── deploy-prod.yml ├── ISSUE_TEMPLATE │ ├── spike.yml │ ├── task.yml │ └── bug.yml ├── pull_request_template.md └── PULL_REQUEST_TEMPLATE │ └── review_template.yml ├── Dockerfile ├── .aws └── task-definition.json └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /public/HelmetLogoFull.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/sarge/main/public/HelmetLogoFull.png -------------------------------------------------------------------------------- /public/CreateOrgLoading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/sarge/main/public/CreateOrgLoading.gif -------------------------------------------------------------------------------- /src/lib/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 2 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ['@tailwindcss/postcss'], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /src/lib/fonts/Satoshi-Variable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/sarge/main/src/lib/fonts/Satoshi-Variable.woff2 -------------------------------------------------------------------------------- /src/lib/fonts/Satoshi-VariableItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/sarge/main/src/lib/fonts/Satoshi-VariableItalic.woff2 -------------------------------------------------------------------------------- /prisma/migrations/20251125164251_test/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "public"."Assessment" ALTER COLUMN "assignedAt" DROP NOT NULL; 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .next 3 | .git 4 | .env* 5 | Dockerfile* 6 | docker-compose*.yml 7 | .next/cache 8 | *.log 9 | !.env.example 10 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" 4 | -------------------------------------------------------------------------------- /prisma/migrations/20251109164334_remove_limit_problem_description/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "public"."TaskTemplate" ALTER COLUMN "content" SET DATA TYPE TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20251130194520_add_task_templates_to_assessment_template/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "public"."AssessmentTemplate" ADD COLUMN "taskTemplates" TEXT[]; 3 | -------------------------------------------------------------------------------- /src/lib/types/member.types.ts: -------------------------------------------------------------------------------- 1 | export type Member = { 2 | id: string; 3 | organizationId: string; 4 | userId: string; 5 | role: string; 6 | createdAt: Date; 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/schemas/role.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const updateRoleSchema = z.object({ 4 | role: z.enum(['owner', 'admin', 'member', 'recruiter', 'reviewer']), 5 | }); 6 | -------------------------------------------------------------------------------- /src/app/api/auth/[...all]/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '@/lib/auth/auth'; 2 | import { toNextJsHandler } from 'better-auth/next-js'; 3 | 4 | export const { GET, POST } = toNextJsHandler(auth); 5 | -------------------------------------------------------------------------------- /src/lib/utils/cn.utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /prisma/seed-data/organizations.seed.ts: -------------------------------------------------------------------------------- 1 | export const organizationsData = [ 2 | { 3 | id: 'org_nextlab_001', 4 | name: 'Next Lab', 5 | slug: 'next-lab', 6 | logo: null, 7 | }, 8 | ]; 9 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # docker-entrypoint.sh 3 | # This script is ran on every deploy within our production docker container 4 | set -eu 5 | echo "Running Prisma migrations..." 6 | npx prisma migrate deploy 7 | echo "Starting app..." 8 | exec "$@" 9 | -------------------------------------------------------------------------------- /prisma/seed-data/positions.seed.ts: -------------------------------------------------------------------------------- 1 | export const positionsData = [ 2 | { 3 | id: 'pos_swe_spring2025_001', 4 | title: 'Software Engineer Spring 2025', 5 | orgId: 'org_nextlab_001', 6 | createdById: 'user_prof_fontenot_001', 7 | }, 8 | ]; 9 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # start.sh 3 | # This script is for developers to use during local development 4 | cleanup() { 5 | docker compose down 6 | exit 0 7 | } 8 | 9 | trap cleanup SIGINT SIGTERM 10 | 11 | docker compose up -d db && pnpm install && pnpm run dev 12 | -------------------------------------------------------------------------------- /prisma/migrations/20251104012828_make_user_password_optional/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `password` on the `User` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "User" DROP COLUMN "password"; 9 | -------------------------------------------------------------------------------- /prisma/seed-data/users.seed.ts: -------------------------------------------------------------------------------- 1 | export const usersData = [ 2 | { 3 | id: 'user_prof_fontenot_001', 4 | name: 'Prof Fontenot', 5 | email: 'p.fontenot@northeastern.edu', 6 | password: 'password123', 7 | role: 'owner' as const, 8 | }, 9 | ]; 10 | -------------------------------------------------------------------------------- /src/lib/types/auth.types.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from '@/generated/prisma'; 2 | 3 | export type AuthSession = Pick & { 4 | activeOrganizationId: string; 5 | }; 6 | 7 | export type GetSessionOptions = { 8 | requireActiveOrg?: boolean; 9 | }; 10 | -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@/generated/prisma'; 2 | 3 | const globalForPrisma = global as unknown as { prisma: PrismaClient }; 4 | 5 | export const prisma = globalForPrisma.prisma || new PrismaClient(); 6 | 7 | if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; 8 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next'; 2 | 3 | const base = process.env.NEXT_PUBLIC_CDN_BASE; 4 | 5 | const nextConfig: NextConfig = { 6 | /* config options here */ 7 | output: 'standalone', 8 | images: base ? { 9 | remotePatterns: [{ protocol: 'https', hostname: new URL(base).hostname, }] 10 | } 11 | : {}, 12 | }; 13 | 14 | export default nextConfig; 15 | -------------------------------------------------------------------------------- /prisma/migrations/20251104013359_change_email_verified_to_boolean/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The `emailVerified` column on the `User` table would be dropped and recreated. This will lead to data loss if there is data in the column. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "User" DROP COLUMN "emailVerified", 9 | ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false; 10 | -------------------------------------------------------------------------------- /src/lib/components/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils/cn.utils'; 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { 4 | return ( 5 |
10 | ); 11 | } 12 | 13 | export { Skeleton }; 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | pnpm-lock.yaml 4 | package-lock.json 5 | yarn.lock 6 | 7 | # Build outputs 8 | .next/ 9 | out/ 10 | build/ 11 | dist/ 12 | 13 | # Generated files 14 | src/generated/ 15 | 16 | # Config files that have their own formatting 17 | *.config.js 18 | *.config.ts 19 | next-env.d.ts 20 | 21 | # Other files 22 | *.log 23 | *.md.backup 24 | -------------------------------------------------------------------------------- /prisma/migrations/20251130180956_add_candidate_fields_and_status/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Made the column `assignedAt` on table `Assessment` required. This step will fail if there are existing NULL values in that column. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "public"."Assessment" ALTER COLUMN "assignedAt" SET NOT NULL; 9 | 10 | -- AlterTable 11 | ALTER TABLE "public"."Task" ALTER COLUMN "lastUpdated" DROP NOT NULL; 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 4, 5 | "trailingComma": "es5", 6 | "printWidth": 100, 7 | "arrowParens": "always", 8 | "endOfLine": "auto", 9 | "bracketSpacing": true, 10 | "bracketSameLine": false, 11 | "plugins": ["prettier-plugin-tailwindcss"], 12 | "tailwindConfig": "./tailwind.config.ts", 13 | "tailwindFunctions": ["cn", "cva", "clsx", "classnames", "tw"] 14 | } 15 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: ["./src/**/*.{ts,tsx,js,jsx,mdx}", "./app/**/*.{ts,tsx,js,jsx,mdx}", "./src/app/**/*.{ts,tsx,js,jsx,mdx}"] , 5 | theme: { 6 | extend: { 7 | fontFamily: { 8 | sans: ['var(--font-sans)', 'var(--font-inter)', 'ui-sans-serif', 'system-ui', '-apple-system', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial'], 9 | }, 10 | }, 11 | }, 12 | plugins: [], 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /prisma/migrations/20251110012155_sync_candidate_pool_entry_status_and_tags/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The `status` column on the `CandidatePoolEntry` table would be dropped and recreated. This will lead to data loss if there is data in the column. 5 | 6 | */ 7 | -- CreateEnum 8 | CREATE TYPE "Status" AS ENUM ('ASSIGNED', 'SUBMITTED'); 9 | 10 | -- AlterTable 11 | ALTER TABLE "CandidatePoolEntry" ADD COLUMN "tags" TEXT[], 12 | DROP COLUMN "status", 13 | ADD COLUMN "status" "Status" NOT NULL DEFAULT 'ASSIGNED'; 14 | -------------------------------------------------------------------------------- /prisma/seed-data/assessment.seed.ts: -------------------------------------------------------------------------------- 1 | export const assessmentsData = [ 2 | { 3 | id: 'assessment_carter_001', 4 | assessmentTemplateId: 'assessment_template_general_001', 5 | uniqueLink: 'https://assessment.sarge.dev/take/carter-herman-001', 6 | deadline: new Date('2035-12-20T23:59:59Z'), 7 | }, 8 | { 9 | id: 'assessment_laith_001', 10 | assessmentTemplateId: 'assessment_template_general_001', 11 | uniqueLink: 'https://assessment.sarge.dev/take/laith-taher-001', 12 | deadline: new Date('2035-12-20T23:59:59Z'), 13 | }, 14 | ]; 15 | -------------------------------------------------------------------------------- /src/lib/schemas/task.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const TaskSchema = z.object({ 4 | id: z.string(), 5 | assessmentId: z.string(), 6 | taskTemplateId: z.string(), 7 | candidateCode: z.string(), 8 | }); 9 | 10 | export const CreateTaskSchema = TaskSchema.omit({ id: true }); 11 | 12 | export const UpdateTaskSchema = TaskSchema.partial().extend({ 13 | id: z.string(), 14 | }); 15 | 16 | export type TaskDTO = z.infer; 17 | export type CreateTaskDTO = z.infer; 18 | export type UpdateTaskDTO = z.infer; 19 | -------------------------------------------------------------------------------- /src/app/api/organizations/[id]/positions/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from 'next/server'; 2 | import PositionService from '@/lib/services/position.service'; 3 | import { handleError } from '@/lib/utils/errors.utils'; 4 | 5 | export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 6 | try { 7 | const orgId = (await params).id; 8 | const positionsResult = await PositionService.getPositionByOrgId(orgId); 9 | return Response.json({ data: positionsResult }, { status: 200 }); 10 | } catch (err) { 11 | return handleError(err); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /prisma/migrations/20251108213442_test_case_json_list/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The `public_test_cases` column on the `TaskTemplate` table would be dropped and recreated. This will lead to data loss if there is data in the column. 5 | - The `private_test_cases` column on the `TaskTemplate` table would be dropped and recreated. This will lead to data loss if there is data in the column. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "public"."TaskTemplate" DROP COLUMN "public_test_cases", 10 | ADD COLUMN "public_test_cases" JSONB[], 11 | DROP COLUMN "private_test_cases", 12 | ADD COLUMN "private_test_cases" JSONB[]; 13 | -------------------------------------------------------------------------------- /src/app/api/tasks/route.ts: -------------------------------------------------------------------------------- 1 | import { CreateTaskSchema } from '@/lib/schemas/task.schema'; 2 | import taskService from '@/lib/services/task.service'; 3 | import { handleError } from '@/lib/utils/errors.utils'; 4 | import { type NextRequest } from 'next/server'; 5 | 6 | export async function POST(request: NextRequest) { 7 | try { 8 | const body = await request.json(); 9 | const parsed = CreateTaskSchema.parse(body); 10 | const result = await taskService.createTask(parsed); 11 | return Response.json({ data: result }, { status: 201 }); 12 | } catch (err) { 13 | return handleError(err); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sarge 2 | 3 | ### Requirements: 4 | 5 | - [PNPM](https://pnpm.io/installation) 6 | - [Docker](https://docs.docker.com/desktop/) 7 | 8 | ### Running in Dev Mode: 9 | 10 | Add the following env variables to a .env file: 11 | 12 | ``` 13 | DB_USER="postgres" 14 | DB_PASSWORD="password" 15 | DB_NAME="sarge" 16 | 17 | AWS_ACCESS_KEY_ID= 18 | AWS_SECRET_ACCESS_KEY= 19 | AWS_SECRET_NAME= 20 | AWS_BUCKET_NAME= 21 | 22 | JUDGE_API_KEY='' 23 | JUDGE_URL='' 24 | 25 | JWT_SECRET="sarge" 26 | NEXT_PUBLIC_CDN_BASE= 27 | DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME} 28 | ``` 29 | 30 | Run the following commands in order 31 | 32 | - Run `./start.sh` 33 | -------------------------------------------------------------------------------- /src/app/api/assessments/route.ts: -------------------------------------------------------------------------------- 1 | import AssessmentService from '@/lib/services/assessment.service'; 2 | import { handleError } from '@/lib/utils/errors.utils'; 3 | import { createAssessmentSchema } from '@/lib/schemas/assessment.schema'; 4 | import { type NextRequest } from 'next/server'; 5 | 6 | export async function POST(request: NextRequest) { 7 | try { 8 | const body = await request.json(); 9 | const parsed = createAssessmentSchema.parse(body); 10 | const result = await AssessmentService.createAssessment(parsed); 11 | return Response.json({ data: result }, { status: 201 }); 12 | } catch (err) { 13 | return handleError(err); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/api/task-templates/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from 'next/server'; 2 | import { handleError } from '@/lib/utils/errors.utils'; 3 | import TaskTemplateService from '@/lib/services/task-template.service'; 4 | import { createTaskTemplateSchema } from '@/lib/schemas/task-template.schema'; 5 | 6 | export async function POST(request: NextRequest) { 7 | try { 8 | const body = await request.json(); 9 | const parsed = createTaskTemplateSchema.parse(body); 10 | const result = await TaskTemplateService.createTaskTemplate(parsed); 11 | return Response.json({ data: result }, { status: 201 }); 12 | } catch (err) { 13 | return handleError(err); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/hooks/useIsMobileShadcn.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | const MOBILE_BREAKPOINT = 768; 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = useState(undefined); 7 | 8 | useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 12 | }; 13 | mql.addEventListener('change', onChange); 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 15 | return () => mql.removeEventListener('change', onChange); 16 | }, []); 17 | 18 | return !!isMobile; 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | /src/generated/prisma 44 | 45 | .vscode 46 | 47 | .vercel 48 | .env*.local 49 | -------------------------------------------------------------------------------- /src/lib/auth/auth-client.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createAuthClient } from 'better-auth/react'; 4 | import { organizationClient } from 'better-auth/client/plugins'; 5 | import { ac, owner, admin, recruiter, reviewer, member } from '@/lib/auth/permissions'; 6 | 7 | export const authClient = createAuthClient({ 8 | plugins: [ 9 | organizationClient({ 10 | ac, 11 | roles: { 12 | owner, 13 | admin, 14 | recruiter, 15 | reviewer, 16 | member, 17 | }, 18 | }), 19 | ], 20 | }); 21 | 22 | export const { signIn, signUp, signOut, useSession, useActiveOrganization, useActiveMember } = 23 | authClient; 24 | -------------------------------------------------------------------------------- /src/lib/components/Search.tsx: -------------------------------------------------------------------------------- 1 | import { SearchIcon } from 'lucide-react'; 2 | import { Input } from './Input'; 3 | 4 | export interface SearchProps { 5 | className?: string; 6 | placeholder?: string; 7 | } 8 | 9 | export function Search({ className, placeholder }: SearchProps) { 10 | return ( 11 |
12 | 13 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/api/candidates/route.ts: -------------------------------------------------------------------------------- 1 | import CandidateService from '@/lib/services/candidate.service'; 2 | import { type NextRequest } from 'next/server'; 3 | import { handleError } from '@/lib/utils/errors.utils'; 4 | import { createCandidateSchema } from '@/lib/schemas/candidate.schema'; 5 | import { getSession } from '@/lib/utils/auth.utils'; 6 | 7 | export async function POST(request: NextRequest) { 8 | try { 9 | const session = await getSession(); 10 | const body = await request.json(); 11 | const parsed = createCandidateSchema.parse(body); 12 | const result = await CandidateService.createCandidate(parsed, session.activeOrganizationId); 13 | return Response.json({ data: result }, { status: 201 }); 14 | } catch (err) { 15 | return handleError(err); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /prisma/seed-data/assessment-template.seed.ts: -------------------------------------------------------------------------------- 1 | export const assessmentTemplatesData = [ 2 | { 3 | id: 'assessment_template_general_001', 4 | title: 'General Software Engineering Assessment', 5 | description: 6 | 'A comprehensive assessment covering fundamental algorithms and data structures', 7 | taskTemplates: [ 8 | 'task_template_two_sum_001', 9 | 'task_template_reverse_string_001', 10 | 'task_template_palindrome_001', 11 | ], 12 | }, 13 | { 14 | id: 'assessment_template_algorithms_001', 15 | title: 'Algorithms Focus Assessment', 16 | description: 'An assessment focused on algorithmic problem-solving skills', 17 | taskTemplates: ['task_template_two_sum_001', 'task_template_palindrome_001'], 18 | }, 19 | ]; 20 | -------------------------------------------------------------------------------- /src/app/(web)/(crm)/positions/page.tsx: -------------------------------------------------------------------------------- 1 | import { Users } from 'lucide-react'; 2 | import PositionService from '@/lib/services/position.service'; 3 | import { getSession } from '@/lib/utils/auth.utils'; 4 | import PositionsContent from './PositionsContent'; 5 | 6 | export default async function PositionsPage() { 7 | const session = await getSession(); 8 | const positions = await PositionService.getPositionByOrgId(session.activeOrganizationId); 9 | 10 | return ( 11 |
12 |
13 | 14 |

Positions

15 |
16 | 17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | ports: 7 | - '3000:3000' 8 | environment: 9 | DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} 10 | depends_on: 11 | - db 12 | volumes: 13 | - .:/app 14 | - /app/node_modules 15 | db: 16 | image: postgres:17 17 | container_name: sarge_dev_db 18 | restart: unless-stopped 19 | environment: 20 | POSTGRES_USER: ${DB_USER} 21 | POSTGRES_PASSWORD: ${DB_PASSWORD} 22 | POSTGRES_DB: ${DB_NAME} 23 | ports: 24 | - '5432:5432' 25 | volumes: 26 | - postgres_data:/var/lib/postgresql/data 27 | 28 | volumes: 29 | postgres_data: 30 | -------------------------------------------------------------------------------- /src/app/(web)/(crm)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { AppSidebar } from '@/lib/components/AppSidebar'; 2 | import { SidebarInset, SidebarProvider } from '@/lib/components/Sidebar'; 3 | import { Toaster } from '@/lib/components/Sonner'; 4 | import { TopNav } from '@/lib/components/TopNav'; 5 | 6 | export default function CRMLayout({ 7 | children, 8 | }: Readonly<{ 9 | children: React.ReactNode; 10 | }>) { 11 | return ( 12 |
13 | 14 |
15 | 16 | 17 | 18 | {children} 19 | 20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/schemas/upload.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const ConfirmBodySchema = z.discriminatedUnion('type', [ 4 | z.object({ 5 | type: z.literal('user'), 6 | userId: z.string(), 7 | key: z.string(), 8 | }), 9 | z.object({ 10 | type: z.literal('organization'), 11 | organizationId: z.string(), 12 | key: z.string(), 13 | }), 14 | ]); 15 | 16 | export const SignBodySchema = z.discriminatedUnion('type', [ 17 | z.object({ 18 | type: z.literal('user'), 19 | mime: z.string().min(3), 20 | userId: z.string(), 21 | organizationId: z.string().optional(), 22 | }), 23 | z.object({ 24 | type: z.literal('organization'), 25 | mime: z.string().min(3), 26 | organizationId: z.string(), 27 | userId: z.string().optional(), 28 | }), 29 | ]); 30 | -------------------------------------------------------------------------------- /src/app/api/candidates/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import CandidateService from '@/lib/services/candidate.service'; 2 | import { type NextRequest } from 'next/server'; 3 | import { handleError } from '@/lib/utils/errors.utils'; 4 | import { updateCandidateSchema } from '@/lib/schemas/candidate.schema'; 5 | 6 | /** 7 | * PUT /api/candidates/[id] 8 | * Update candidate information 9 | */ 10 | export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 11 | try { 12 | const candidateId = (await params).id; 13 | const body = await request.json(); 14 | const parsed = updateCandidateSchema.parse(body); 15 | const result = await CandidateService.updateCandidate(candidateId, parsed); 16 | return Response.json({ data: result }, { status: 200 }); 17 | } catch (err) { 18 | return handleError(err); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/prettier-check.yml: -------------------------------------------------------------------------------- 1 | name: Prettier Check 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - develop 7 | pull_request: 8 | branches: 9 | - main 10 | - develop 11 | - 'feature/**' 12 | jobs: 13 | run-prettier-check: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 10 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v3 19 | - name: Setup pnpm 20 | uses: pnpm/action-setup@v4 21 | - name: Set up Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 22 25 | cache: 'pnpm' 26 | - name: Install modules 27 | run: pnpm install 28 | - name: Run prettier check 29 | run: pnpm prettier:check 30 | -------------------------------------------------------------------------------- /src/app/api/judge/route.ts: -------------------------------------------------------------------------------- 1 | import { createBatchSubmission, getBatchSubmission } from '@/lib/connectors/judge0.connector'; 2 | import { handleError } from '@/lib/utils/errors.utils'; 3 | import { type NextRequest } from 'next/server'; 4 | 5 | export async function POST(request: NextRequest) { 6 | try { 7 | const body = await request.json(); 8 | const tokens = await createBatchSubmission(body); 9 | return Response.json({ data: tokens }, { status: 201 }); 10 | } catch (err) { 11 | return handleError(err); 12 | } 13 | } 14 | 15 | export async function GET(request: NextRequest) { 16 | try { 17 | const tokens = request.nextUrl.searchParams.getAll('tokens'); 18 | const results = await getBatchSubmission(tokens); 19 | return Response.json({ data: results }, { status: 200 }); 20 | } catch (err) { 21 | return handleError(err); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/components/Separator.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as SeparatorPrimitive from '@radix-ui/react-separator'; 5 | 6 | import { cn } from '@/lib/utils/cn.utils'; 7 | 8 | function Separator({ 9 | className, 10 | orientation = 'horizontal', 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ); 26 | } 27 | 28 | export { Separator }; 29 | -------------------------------------------------------------------------------- /src/app/api/assessment-templates/route.ts: -------------------------------------------------------------------------------- 1 | import { CreateAssessmentTemplateSchema } from '@/lib/schemas/assessment-template.schema'; 2 | import AssessmentTemplateService from '@/lib/services/assessment-template.service'; 3 | import { getSession } from '@/lib/utils/auth.utils'; 4 | import { handleError } from '@/lib/utils/errors.utils'; 5 | import { type NextRequest } from 'next/server'; 6 | 7 | export async function POST(request: NextRequest) { 8 | try { 9 | const body = await request.json(); 10 | const session = await getSession(); 11 | const parsed = CreateAssessmentTemplateSchema.parse({ 12 | ...body, 13 | orgId: session.activeOrganizationId, 14 | }); 15 | const result = await AssessmentTemplateService.createAssessmentTemplate(parsed); 16 | return Response.json({ data: result }, { status: 201 }); 17 | } catch (err) { 18 | return handleError(err); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest, NextResponse } from 'next/server'; 2 | import { getCookieCache } from 'better-auth/cookies'; 3 | 4 | const protectedRoutes = ['/dashboard']; 5 | 6 | export async function middleware(request: NextRequest) { 7 | const { pathname } = request.nextUrl; 8 | const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route)); 9 | 10 | if (isProtectedRoute) { 11 | // Get session from cookie cache (optimistic check for middleware performance) 12 | // Note: Full session validation happens on the server/page level 13 | const session = await getCookieCache(request); 14 | 15 | if (!session) { 16 | return NextResponse.redirect(new URL('/signin', request.url)); 17 | } 18 | } 19 | 20 | return NextResponse.next(); 21 | } 22 | 23 | export const config = { 24 | matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], 25 | }; 26 | -------------------------------------------------------------------------------- /src/lib/components/Chip.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils/cn.utils'; 2 | 3 | export type ChipVariant = 'neutral' | 'success' | 'error' | 'warning' | 'primary'; 4 | 5 | interface ChipProps { 6 | children: React.ReactNode; 7 | variant?: ChipVariant; 8 | className?: string; 9 | } 10 | 11 | export function Chip({ children, variant = 'neutral', className }: ChipProps) { 12 | const base = 'inline-flex items-center px-2 py-1 rounded-lg text-label-xs'; 13 | const variantStyles: Record = { 14 | neutral: 'bg-sarge-gray-200 text-sarge-gray-600', 15 | success: 'bg-sarge-success-100 text-sarge-success-800', 16 | error: 'bg-sarge-error-200 text-sarge-error-700', 17 | warning: 'bg-sarge-warning-100 text-sarge-warning-500', 18 | primary: 'bg-sarge-primary-200 text-sarge-primary-600', 19 | }; 20 | 21 | return {children}; 22 | } 23 | -------------------------------------------------------------------------------- /src/app/api/organizations/route.ts: -------------------------------------------------------------------------------- 1 | import OrganizationService from '@/lib/services/organization.service'; 2 | import { type NextRequest } from 'next/server'; 3 | import { handleError } from '@/lib/utils/errors.utils'; 4 | import { createOrganizationSchema } from '@/lib/schemas/organization.schema'; 5 | import { getSessionWithoutOrg } from '@/lib/utils/auth.utils'; 6 | 7 | export async function POST(request: NextRequest) { 8 | try { 9 | const session = await getSessionWithoutOrg(); 10 | 11 | const body = await request.json(); 12 | const createOrgRequest = createOrganizationSchema.parse(body); 13 | 14 | const organization = await OrganizationService.createOrganization( 15 | createOrgRequest, 16 | session.userId, 17 | request.headers 18 | ); 19 | 20 | return Response.json({ data: organization }, { status: 201 }); 21 | } catch (err) { 22 | return handleError(err); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/schemas/organization.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const createOrganizationSchema = z.object({ 4 | name: z 5 | .string() 6 | .min(2, 'Name must be at least 2 characters') 7 | .max(100, 'Name must be less than 100 characters') 8 | .trim(), 9 | createdById: z.string(), 10 | }); 11 | 12 | export const getOrganizationSchema = z.object({ 13 | id: z.string('Invalid organization ID'), 14 | }); 15 | 16 | export const updateOrganizationSchema = z.object({ 17 | name: z 18 | .string() 19 | .min(2, 'Name must be at least 2 characters') 20 | .max(100, 'Name must be less than 100 characters') 21 | .trim(), 22 | logo: z.url(), 23 | }); 24 | 25 | export type CreateOrganizationDTO = z.infer; 26 | export type UpdateOrganizationDTO = z.infer; 27 | export type GetOrganizationDTO = z.infer; 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/spike.yml: -------------------------------------------------------------------------------- 1 | name: Spike 2 | description: Use this template for research or investigation tasks. 3 | title: '[SPIKE] ' 4 | labels: ['spike', 'investigation'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | A spike is a time-boxed research task to explore solutions or gather information. 10 | - type: textarea 11 | id: objective 12 | attributes: 13 | label: Objective 14 | description: What’s the main question or problem this spike is trying to answer? 15 | placeholder: 'We want to understand if library X can handle our use case...' 16 | validations: 17 | required: true 18 | - type: textarea 19 | id: resources 20 | attributes: 21 | label: Resources / Notes 22 | description: Links, references, or any notes gathered during the spike. 23 | placeholder: 'Documentation links, small code snippets, observations...' 24 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import Link from 'next/link'; 3 | import { Button } from '@/lib/components/Button'; 4 | 5 | export default function Home() { 6 | return ( 7 |
8 |
9 | Sarge Logo 16 | 17 |

18 | This isn't a drill, Sarge. Log in. 19 |

20 | 21 | 24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/api/organizations/[id]/members/[memberId]/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from 'next/server'; 2 | import { handleError } from '@/lib/utils/errors.utils'; 3 | import { updateRoleSchema } from '@/lib/schemas/role.schema'; 4 | import MemberService from '@/lib/services/member.service'; 5 | 6 | export async function PATCH( 7 | request: NextRequest, 8 | { params }: { params: Promise<{ id: string; memberId: string }> } 9 | ) { 10 | try { 11 | const { id: orgId, memberId: memberIdToUpdate } = await params; 12 | 13 | const body = await request.json(); 14 | const { role } = updateRoleSchema.parse(body); 15 | 16 | const updatedMember = await MemberService.updateMemberRole( 17 | memberIdToUpdate, 18 | role, 19 | orgId, 20 | request.headers 21 | ); 22 | 23 | return Response.json({ data: updatedMember }, { status: 200 }); 24 | } catch (err) { 25 | return handleError(err); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cn } from '@/lib/utils/cn.utils'; 3 | 4 | export const Input = React.forwardRef< 5 | HTMLInputElement, 6 | React.InputHTMLAttributes 7 | >(({ className, type, ...props }, ref) => { 8 | const hasError = props['aria-invalid'] === true; 9 | const borderColor = hasError 10 | ? 'border-sarge-error-700 hover:border-sarge-error-700 focus:border-sarge-error-700' 11 | : 'border-sarge-gray-200 hover:border-sarge-gray-600 focus:border-sarge-gray-600'; 12 | 13 | return ( 14 | 24 | ); 25 | }); 26 | Input.displayName = 'Input'; 27 | -------------------------------------------------------------------------------- /src/app/api/position/[id]/candidates/csv/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from 'next/server'; 2 | import { handleError, BadRequestException } from '@/lib/utils/errors.utils'; 3 | import { parseCandidateCsv } from '@/lib/utils/csv.utils'; 4 | 5 | export async function POST(request: NextRequest) { 6 | try { 7 | const formData = await request.formData(); 8 | const file = formData.get('file'); 9 | 10 | if (!(file instanceof File)) { 11 | throw new BadRequestException('CSV file is required.'); 12 | } 13 | 14 | const filename = file.name?.toLowerCase() ?? ''; 15 | const mimeType = file.type?.toLowerCase() ?? ''; 16 | 17 | if (!(filename.endsWith('.csv') || mimeType.includes('csv'))) { 18 | throw new BadRequestException('File must be a CSV.'); 19 | } 20 | 21 | const text = await file.text(); 22 | const candidates = parseCandidateCsv(text); 23 | 24 | return Response.json({ data: candidates }, { status: 200 }); 25 | } catch (err) { 26 | return handleError(err); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/api/position/[id]/candidates/[candidateId]/route.ts: -------------------------------------------------------------------------------- 1 | import CandidatePoolService from '@/lib/services/candidate-pool.service'; 2 | import { handleError } from '@/lib/utils/errors.utils'; 3 | import { type NextRequest } from 'next/server'; 4 | import { getSession } from '@/lib/utils/auth.utils'; 5 | 6 | /** 7 | * DELETE /api/position/[id]/candidates/[candidateId] 8 | * Remove a single candidate from a position's pool 9 | * This will cascade delete their assessment if one exists 10 | */ 11 | export async function DELETE( 12 | _request: NextRequest, 13 | { params }: { params: Promise<{ id: string; candidateId: string }> } 14 | ) { 15 | try { 16 | const session = await getSession(); 17 | const { id: positionId, candidateId } = await params; 18 | const result = await CandidatePoolService.removeCandidateFromPosition( 19 | candidateId, 20 | positionId, 21 | session.activeOrganizationId 22 | ); 23 | return Response.json({ data: result }, { status: 200 }); 24 | } catch (err) { 25 | return handleError(err); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/hooks/useFileClient.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | export default function useFileClient() { 4 | const [file, setFile] = useState(null); 5 | const [preview, setPreview] = useState(''); 6 | const fileInputRef = useRef(null); 7 | 8 | useEffect(() => { 9 | return () => { 10 | if (preview) URL.revokeObjectURL(preview); 11 | }; 12 | }, [preview]); 13 | 14 | const handleFileChange = (e: React.ChangeEvent) => { 15 | if (preview) URL.revokeObjectURL(preview); 16 | 17 | const nextFile = e.target.files?.[0]; 18 | if (!nextFile) return; 19 | 20 | const url = URL.createObjectURL(nextFile); 21 | setFile(nextFile); 22 | setPreview(url); 23 | }; 24 | 25 | const handleProfileImageClick = () => { 26 | fileInputRef.current?.click(); 27 | }; 28 | 29 | return { 30 | file, 31 | preview, 32 | fileInputRef, 33 | handleFileChange, 34 | handleProfileImageClick, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/schemas/assessment.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const getAssessmentSchema = z.object({ 4 | id: z.cuid(), 5 | }); 6 | 7 | export const assessmentSchema = z.object({ 8 | id: z.string(), 9 | candidatePoolEntryId: z.string(), 10 | assessmentTemplateId: z.string(), 11 | deadline: z.date().nullable(), 12 | assignedAt: z.date(), 13 | uniqueLink: z.string(), 14 | submittedAt: z.date().nullable(), 15 | }); 16 | 17 | export const createAssessmentSchema = assessmentSchema.omit({ id: true }); 18 | 19 | export const updateAssessmentTemplateSchema = assessmentSchema.partial().extend({ 20 | id: z.string(), 21 | }); 22 | 23 | export const deleteAssessmentSchema = z.object({ 24 | id: z.string(), 25 | }); 26 | 27 | export type GetAssessmentDTO = z.infer; 28 | export type UpdateAssessmentDTO = z.infer; 29 | export type DeleteAssessmentDTO = z.infer; 30 | export type Assessment = z.infer; 31 | export type CreateAssessmentDTO = z.infer; 32 | -------------------------------------------------------------------------------- /.github/workflows/lint-check.yml: -------------------------------------------------------------------------------- 1 | name: Linting Check 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - develop 7 | pull_request: 8 | branches: 9 | - main 10 | - develop 11 | - 'feature/**' 12 | jobs: 13 | run-linting-check: 14 | runs-on: ubuntu-latest 15 | env: 16 | NEXT_PUBLIC_CDN_BASE: ${{ secrets.NEXT_PUBLIC_CDN_BASE }} 17 | timeout-minutes: 10 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v3 21 | - name: Setup pnpm 22 | uses: pnpm/action-setup@v4 23 | - name: Set up Node.js 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: 22 27 | cache: 'pnpm' 28 | - name: Install modules 29 | run: pnpm install 30 | - name: Generate Prisma client 31 | run: pnpm prisma:generate 32 | - name: Run linting check 33 | run: pnpm lint 34 | - name: Build (validation) 35 | run: pnpm build:check 36 | -------------------------------------------------------------------------------- /src/app/(web)/(crm)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import OnboardingModal from '@/lib/components/modal/OnboardingModal'; 4 | import useOnboardingState from '@/lib/hooks/useOnboardingState'; 5 | import Image from 'next/image'; 6 | 7 | export default function DashboardPage() { 8 | const { isOnboarding, isSignedOut, isUserLoading } = useOnboardingState(); 9 | 10 | if (isUserLoading) return
Loading...
; 11 | if (isSignedOut) return
You must be signed in...
; 12 | 13 | return ( 14 |
15 | {isOnboarding ? ( 16 | 17 | ) : ( 18 |
19 | Helmet Logo 26 |

Coming 2026...

27 |
28 | )} 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/(web)/(oa)/assessment/[assessmentId]/outro/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@/lib/components/Button'; 4 | import useAssessment from '@/lib/hooks/useAssessment'; 5 | import { use } from 'react'; 6 | 7 | export default function OutroAssessmentPage({ 8 | params, 9 | }: { 10 | params: Promise<{ assessmentId: string }>; 11 | }) { 12 | const { assessmentId } = use(params); 13 | const { loading, error, assessment, submitAssessment } = useAssessment(assessmentId); 14 | 15 | if (loading) { 16 | return
Loading...
; 17 | } 18 | 19 | if (error) { 20 | return
Error: {error.message}
; 21 | } 22 | 23 | if (assessment?.candidatePoolEntry.assessmentStatus === 'SUBMITTED') { 24 | return
This assessment has already been submitted.
; 25 | } 26 | 27 | return ( 28 |
29 |

Thank you!

30 | 31 |

You have completed the assessment.

32 | 33 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /public/Helmet Logomark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:22-slim AS builder 2 | WORKDIR /app 3 | 4 | RUN apt-get update -y && apt-get install -y openssl ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* 5 | 6 | COPY package.json pnpm-lock.yaml* ./ 7 | RUN corepack enable && pnpm install --frozen-lockfile 8 | 9 | ARG NEXT_PUBLIC_CDN_BASE 10 | ENV NEXT_PUBLIC_CDN_BASE=$NEXT_PUBLIC_CDN_BASE 11 | 12 | COPY . . 13 | RUN pnpm prisma:generate 14 | RUN pnpm build 15 | 16 | FROM public.ecr.aws/docker/library/node:22-slim 17 | WORKDIR /app 18 | ENV NODE_ENV=production 19 | ENV PORT=3000 20 | # TODO: check if AWS needs this env variable 21 | # ENV HOST=0.0.0.0 22 | ENV HOSTNAME=0.0.0.0 23 | EXPOSE 3000 24 | 25 | RUN apt-get update -y && apt-get install -y openssl ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* 26 | 27 | COPY --from=builder /app/.next/standalone ./ 28 | COPY --from=builder /app/.next/static ./.next/static 29 | # TODO: uncomment when we add public directory 30 | # COPY --from=builder /app/public ./public 31 | 32 | COPY --from=builder /app/prisma ./prisma 33 | 34 | COPY docker-entrypoint.sh /entrypoint.sh 35 | RUN chmod +x /entrypoint.sh 36 | ENTRYPOINT ["/entrypoint.sh"] 37 | 38 | CMD ["node", "server.js"] 39 | -------------------------------------------------------------------------------- /src/app/api/position/[id]/candidates/batch/route.ts: -------------------------------------------------------------------------------- 1 | import CandidatePoolService from '@/lib/services/candidate-pool.service'; 2 | import { handleError } from '@/lib/utils/errors.utils'; 3 | import { type NextRequest } from 'next/server'; 4 | import { getSession } from '@/lib/utils/auth.utils'; 5 | import { batchAddCandidatesSchema } from '@/lib/schemas/candidate-pool.schema'; 6 | 7 | /** 8 | * POST /api/position/[id]/candidates/batch 9 | * Batch add candidates to a position (CSV upload flow) 10 | */ 11 | export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 12 | try { 13 | const session = await getSession(); 14 | const positionId = (await params).id; 15 | const body = await request.json(); 16 | const parsed = batchAddCandidatesSchema.parse(body); 17 | 18 | const result = await CandidatePoolService.batchAddCandidatesToPosition( 19 | parsed.candidates, 20 | positionId, 21 | session.activeOrganizationId 22 | ); 23 | 24 | return Response.json({ data: result }, { status: 201 }); 25 | } catch (err) { 26 | return handleError(err); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/schemas/assessment-template.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const AssessmentTemplateSchema = z.object({ 4 | id: z.string(), 5 | title: z.string(), 6 | description: z.string(), 7 | orgId: z.string(), 8 | taskTemplates: z.array(z.string()).min(1), 9 | }); 10 | 11 | export const CreateAssessmentTemplateSchema = AssessmentTemplateSchema.omit({ 12 | id: true, 13 | }); 14 | 15 | export const UpdateAssessmentTemplateSchema = z.object({ 16 | id: z.string(), 17 | title: z.string().optional(), 18 | description: z.string().optional(), 19 | }); 20 | 21 | export const GetAssessmentTemplateSchema = z.object({ 22 | id: z.string(), 23 | }); 24 | 25 | export const DeleteAssessmentTemplateSchema = z.object({ 26 | id: z.string(), 27 | }); 28 | 29 | export type CreateAssessmentTemplateDTO = z.infer; 30 | export type UpdateAssessmentTemplateDTO = z.infer; 31 | export type GetAssessmentTemplateDTO = z.infer; 32 | export type DeleteAssessmentTemplateDTO = z.infer; 33 | export type AssessmentTemplate = z.infer; 34 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # [Area] - Short Description 2 | 3 | ## Changes 4 | 5 | Explain what changes were made 6 | _Example_: Explanation of changes goes here 7 | 8 | --- 9 | 10 | ## Notes 11 | 12 | Any additional notes about this PR 13 | _Example_: Any other notes go here 14 | 15 | --- 16 | 17 | ## Screenshots 18 | 19 | If UI changes were made, attach screenshots (normal window + smallest window). 20 | If manual testing was done, include HTTP request screenshots or DB before/after. 21 | If none apply, remove this section. 22 | 23 | _Place screenshots here_ 24 | 25 | --- 26 | 27 | ## Checklist 28 | 29 | Please go through all items before requesting reviewers: 30 | 31 | - [ ] All commits are tagged with the ticket number 32 | - [ ] No linting errors / newline warnings 33 | - [ ] All code follows repository-configured formatting 34 | - [ ] No merge conflicts 35 | - [ ] All checks passing 36 | - [ ] Screenshots included for UI changes 37 | - [ ] Remove non-applicable sections of this template 38 | - [ ] PR assigned to yourself 39 | - [ ] Reviewers requested & Slack ping sent 40 | - [ ] PR linked to the issue (fill in 'Closes #') 41 | - [ ] If design-related, notify the designer in Slack 42 | 43 | --- 44 | 45 | ## Closes 46 | 47 | Closes # 48 | -------------------------------------------------------------------------------- /src/app/actions/position.actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { revalidatePath } from 'next/cache'; 4 | import { getSession } from '@/lib/utils/auth.utils'; 5 | import PositionService from '@/lib/services/position.service'; 6 | import { createPositionSchema } from '@/lib/schemas/position.schema'; 7 | 8 | export async function createPositionAction(title: string): Promise { 9 | const session = await getSession(); 10 | const parsed = createPositionSchema.parse({ title }); 11 | 12 | await PositionService.createPosition(parsed, session.userId, session.activeOrganizationId); 13 | 14 | revalidatePath('/positions'); 15 | } 16 | 17 | export async function getPositionPreviewAction(positionId: string) { 18 | await getSession(); 19 | 20 | const position = await PositionService.getPositionPreview(positionId); 21 | 22 | return { 23 | ...position, 24 | createdAt: position.createdAt.toISOString(), 25 | candidates: position.candidates.map((c) => ({ 26 | ...c, 27 | assessment: c.assessment 28 | ? { 29 | ...c.assessment, 30 | submittedAt: c.assessment.submittedAt?.toISOString() ?? null, 31 | } 32 | : null, 33 | })), 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/components/TopNav.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image from 'next/image'; 4 | import { useAuthSession } from '@/lib/auth/auth-context'; 5 | 6 | export function TopNav() { 7 | const { user } = useAuthSession(); 8 | const userInitial = user?.name?.charAt(0).toUpperCase() ?? 'U'; 9 | 10 | return ( 11 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/hooks/useOnboardingModal.ts: -------------------------------------------------------------------------------- 1 | import useOnboardingState from '@/lib/hooks/useOnboardingState'; 2 | import useFileClient from '@/lib/hooks/useFileClient'; 3 | import useOrganizationCreation from '@/lib/hooks/useOrganizationCreation'; 4 | 5 | function useOnboardingModal() { 6 | const { userId, step, open, goTo, onOpenChange, isOnboarding, isSignedOut, isUserLoading } = 7 | useOnboardingState(); 8 | 9 | const org = useOrganizationCreation(userId); 10 | 11 | const { file, preview, fileInputRef, handleFileChange, handleProfileImageClick } = 12 | useFileClient(); 13 | 14 | const createOrganization = () => org.createOrganization(file); 15 | 16 | return { 17 | step, 18 | open, 19 | onOpenChange, 20 | goTo, 21 | toWelcome: () => goTo('welcome'), 22 | toCreate: () => goTo('create'), 23 | toJoin: () => goTo('join'), 24 | close: () => goTo(null), 25 | isOnboarding, 26 | isSignedOut, 27 | isUserLoading, 28 | 29 | createOrganization, 30 | organizationName: org.organizationName, 31 | setOrganizationName: org.setOrganizationName, 32 | error: org.error, 33 | loading: org.loading, 34 | 35 | preview, 36 | fileInputRef, 37 | handleFileChange, 38 | handleProfileImageClick, 39 | }; 40 | } 41 | 42 | export default useOnboardingModal; 43 | -------------------------------------------------------------------------------- /public/example-candidates.csv: -------------------------------------------------------------------------------- 1 | name,email,major,graduationdate,resumeurl,linkedinurl,githuburl 2 | Carter Herman,carter.herman@example.com,Computer Science,2025,https://example.com/resume/carter-herman.pdf,https://linkedin.com/in/carterherman,https://github.com/carterherman 3 | Laith Taher,laith.taher@example.com,Software Engineering,2024,https://example.com/resume/laith-taher.pdf,https://linkedin.com/in/laithtaher,https://github.com/laithtaher 4 | Brad Derby,brad.derby@example.com,Data Science,2026,https://example.com/resume/brad-derby.pdf,https://linkedin.com/in/bradderby,https://github.com/bderby 5 | Olivia Li,olivia.li@example.com,Computer Science,2025,https://example.com/resume/olivia-li.pdf,https://linkedin.com/in/oliviali,https://github.com/oliviali 6 | Bea Luna,bea.luna@example.com,Software Engineering,2025,https://example.com/resume/bea-luna.pdf,https://linkedin.com/in/bealuna,https://github.com/bealuna 7 | Lea Lang,lea.lang@example.com,Computer Science,2026,https://example.com/resume/lea-lang.pdf,https://linkedin.com/in/lealang,https://github.com/lealang 8 | Meggan Schvartsberg,meggan.s@example.com,Data Science,2024,https://example.com/resume/meggan-schvartsberg.pdf,https://linkedin.com/in/megganschvartsberg,https://github.com/megganschvartsberg 9 | Anzhuo Wang,anzhuo.wang@example.com,Software Engineering,2025,https://example.com/resume/anzhuo-wang.pdf,https://linkedin.com/in/anzhuowang,https://github.com/anzhuowang 10 | -------------------------------------------------------------------------------- /src/app/api/users/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import UserService from '@/lib/services/user.service'; 2 | import { handleError } from '@/lib/utils/errors.utils'; 3 | 4 | import { type NextRequest } from 'next/server'; 5 | import { UserSchema } from '@/lib/schemas/user.schema'; 6 | 7 | export async function DELETE( 8 | _request: NextRequest, 9 | { params }: { params: Promise<{ id: string }> } 10 | ) { 11 | try { 12 | const result = await UserService.deleteUser((await params).id); 13 | return Response.json({ data: result }, { status: 200 }); 14 | } catch (err) { 15 | return handleError(err); 16 | } 17 | } 18 | 19 | export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 20 | try { 21 | const result = await UserService.getUser((await params).id); 22 | return Response.json({ data: result }, { status: 200 }); 23 | } catch (err) { 24 | return handleError(err); 25 | } 26 | } 27 | 28 | export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 29 | try { 30 | const body = await request.json(); 31 | const parsed = UserSchema.partial().parse(body); 32 | const result = await UserService.updateUser((await params).id, parsed); 33 | return Response.json({ data: result }, { status: 200 }); 34 | } catch (err) { 35 | return handleError(err); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/api/assessments/[id]/status/route.ts: -------------------------------------------------------------------------------- 1 | import AssessmentService from '@/lib/services/assessment.service'; 2 | import CandidatePoolService from '@/lib/services/candidate-pool.service'; 3 | import { handleError } from '@/lib/utils/errors.utils'; 4 | import type { NextRequest } from 'next/dist/server/web/spec-extension/request'; 5 | 6 | export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 7 | try { 8 | const { id } = await params; 9 | const assessment = await AssessmentService.getAssessmentWithRelations(id); 10 | const result = await CandidatePoolService.getAssessmentStatus( 11 | assessment.candidatePoolEntryId 12 | ); 13 | return Response.json({ data: result }, { status: 200 }); 14 | } catch (err) { 15 | return handleError(err); 16 | } 17 | } 18 | 19 | export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 20 | try { 21 | const { id } = await params; 22 | const body = await request.json(); 23 | const assessment = await AssessmentService.getAssessmentWithRelations(id); 24 | const result = await CandidatePoolService.updateAssessmentStatus({ 25 | id: assessment.candidatePoolEntryId, 26 | ...body, 27 | }); 28 | return Response.json({ data: result }, { status: 200 }); 29 | } catch (err) { 30 | return handleError(err); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/task.yml: -------------------------------------------------------------------------------- 1 | name: Task 2 | description: Create a task that a member can complete. 3 | title: '[Area] - Short Description' 4 | body: 5 | - type: textarea 6 | id: description 7 | attributes: 8 | label: Description 9 | description: Provide a brief summary of this issue 10 | validations: 11 | required: true 12 | 13 | - type: textarea 14 | id: acceptance-criteria 15 | attributes: 16 | label: Acceptance Criteria 17 | description: What are the conditions that need to be satisified to complete this task? 18 | validations: 19 | required: true 20 | 21 | - type: input 22 | id: blocked-by 23 | attributes: 24 | label: Blocked By 25 | description: Link any issues that are blocking this issue from being complete (if applicable) 26 | validations: 27 | required: false 28 | 29 | - type: input 30 | id: figma-link 31 | attributes: 32 | label: Figma Link 33 | description: Provide the link to the specific design (if applicable) 34 | placeholder: https://www.figma.com/file/... 35 | validations: 36 | required: false 37 | 38 | - type: textarea 39 | id: mocks 40 | attributes: 41 | label: Mocks / Screenshots 42 | description: Provide mocks, screenshots, or other visuals of this new feature 43 | validations: 44 | required: false 45 | -------------------------------------------------------------------------------- /src/lib/schemas/task-template.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const testCaseSchema = z.object({ 4 | input: z.string().min(1, 'Expected input required'), 5 | output: z.string().min(1, 'Expected output required'), 6 | }); 7 | 8 | export const getTaskTemplateSchema = z.object({ 9 | id: z.string(), 10 | }); 11 | 12 | export const TaskTemplateSchema = z.object({ 13 | id: z.string(), 14 | title: z.string().trim(), 15 | content: z.string().min(2), 16 | orgId: z.string(), 17 | public_test_cases: z 18 | .array(testCaseSchema) 19 | .min(1, 'There must at least be one public test case'), // Might not have to be required 20 | private_test_cases: z.array(testCaseSchema).default([]), 21 | tags: z.array(z.string()).optional(), 22 | }); 23 | 24 | export const createTaskTemplateSchema = TaskTemplateSchema.omit({ id: true }); 25 | 26 | export const deleteTaskTemplateSchema = z.object({ 27 | id: z.string(), 28 | }); 29 | 30 | export const updateTaskTemplateSchema = TaskTemplateSchema.partial().extend({ 31 | id: z.string(), 32 | }); 33 | 34 | export type TaskTemplateDTO = z.infer; 35 | export type GetTaskTemplateDTO = z.infer; 36 | export type CreateTaskTemplateDTO = z.infer; 37 | export type DeleteTaskTemplateDTO = z.infer; 38 | export type UpdateTaskTemplateDTO = z.infer; 39 | -------------------------------------------------------------------------------- /src/app/api/tasks/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { UpdateTaskSchema } from '@/lib/schemas/task.schema'; 2 | import taskService from '@/lib/services/task.service'; 3 | import { handleError } from '@/lib/utils/errors.utils'; 4 | import { type NextRequest } from 'next/server'; 5 | 6 | export async function GET(_requst: NextRequest, { params }: { params: Promise<{ id: string }> }) { 7 | try { 8 | const { id } = await params; 9 | const result = await taskService.getTask(id); 10 | return Response.json({ data: result }, { status: 200 }); 11 | } catch (err) { 12 | return handleError(err); 13 | } 14 | } 15 | 16 | export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { 17 | try { 18 | const { id } = await params; 19 | const result = await taskService.deleteTask(id); 20 | return Response.json({ data: result }, { status: 200 }); 21 | } catch (err) { 22 | return handleError(err); 23 | } 24 | } 25 | 26 | export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 27 | try { 28 | const { id } = await params; 29 | const body = await request.json(); 30 | const parsed = UpdateTaskSchema.parse({ id, ...body }); 31 | const result = await taskService.updateTask(parsed); 32 | return Response.json({ data: result }, { status: 200 }); 33 | } catch (err) { 34 | return handleError(err); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.aws/task-definition.json: -------------------------------------------------------------------------------- 1 | { 2 | "family": "sarge", 3 | "networkMode": "awsvpc", 4 | "requiresCompatibilities": ["FARGATE"], 5 | "cpu": "512", 6 | "memory": "1024", 7 | "executionRoleArn": "arn:aws:iam::163885032113:role/ecsTaskExecutionRole", 8 | "containerDefinitions": [ 9 | { 10 | "name": "web", 11 | "image": "REPLACED-BY-ACTION", 12 | "essential": true, 13 | "portMappings": [{ "containerPort": 3000, "protocol": "tcp" }], 14 | "environment": [ 15 | { "name": "HOSTNAME", "value": "0.0.0.0" }, 16 | { "name": "PORT", "value": "3000" } 17 | ], 18 | "secrets": [ 19 | { 20 | "name": "DATABASE_URL", 21 | "valueFrom": "arn:aws:secretsmanager:us-east-2:163885032113:secret:sarge/prod-1d8Jug:DATABASE_URL::" 22 | }, 23 | { 24 | "name": "NEXT_PUBLIC_CDN_BASE", 25 | "valueFrom": "arn:aws:secretsmanager:us-east-2:163885032113:secret:sarge/prod-1d8Jug:CDN_BASE::" 26 | } 27 | ], 28 | "logConfiguration": { 29 | "logDriver": "awslogs", 30 | "options": { 31 | "awslogs-region": "us-east-2", 32 | "awslogs-group": "/ecs/sarge", 33 | "awslogs-stream-prefix": "web" 34 | } 35 | } 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/schemas/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const createUserSchema = z.object({ 4 | name: z 5 | .string() 6 | .min(2, 'Name must be at least 2 characters') 7 | .max(100, 'Name must be less than 100 characters') 8 | .trim(), 9 | email: z 10 | .string() 11 | .min(1, 'Email is required') 12 | .email('Invalid email format') 13 | .toLowerCase() 14 | .trim() 15 | .max(255, 'Email must be less than 255 characters'), 16 | password: z 17 | .string() 18 | .min(8, 'Password must be at least 8 characters') 19 | .max(128, 'Password must be less than 128 characters'), 20 | }); 21 | 22 | export const loginUserSchema = z.object({ 23 | email: z 24 | .string() 25 | .min(1, 'Email is required') 26 | .email('Invalid email format') 27 | .toLowerCase() 28 | .trim() 29 | .max(255, 'Email must be less than 255 characters'), 30 | password: z 31 | .string() 32 | .min(1, 'Password is required') 33 | .min(8, 'Password must be at least 8 characters'), 34 | }); 35 | 36 | export const UserSchema = z.object({ 37 | id: z.string(), 38 | name: createUserSchema.shape.name, 39 | email: createUserSchema.shape.email, 40 | }); 41 | 42 | export type UserDTO = z.infer; 43 | 44 | export type CreateUserDTO = z.infer; 45 | 46 | export type LoginUserDTO = z.infer; 47 | -------------------------------------------------------------------------------- /src/app/(web)/(oa)/assessment/[assessmentId]/intro/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@/lib/components/Button'; 4 | import useAssessment from '@/lib/hooks/useAssessment'; 5 | import { use } from 'react'; 6 | 7 | export default function IntroAssessmentPage({ 8 | params, 9 | }: { 10 | params: Promise<{ assessmentId: string }>; 11 | }) { 12 | const { assessmentId } = use(params); 13 | const { loading, error, assessment, startAssessment } = useAssessment(assessmentId); 14 | 15 | if (loading) { 16 | return
Loading...
; 17 | } 18 | 19 | if (error) { 20 | return
Error: {error.message}
; 21 | } 22 | 23 | if (assessment?.deadline && new Date() > new Date(assessment.deadline)) { 24 | return
Assessment deadline has passed
; 25 | } 26 | 27 | if (assessment?.candidatePoolEntry.assessmentStatus !== 'ASSIGNED') { 28 | return
Assessment is not open
; 29 | } 30 | 31 | return ( 32 |
33 |

{assessment?.assessmentTemplate.title}

34 | 35 |

36 | You are about to start the assessment. 37 | {assessment?.deadline && ( 38 | <> Deadline: {new Date(assessment.deadline).toLocaleDateString()} 39 | )} 40 |

41 | 42 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/app/api/assessments/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import AssessmentService from '@/lib/services/assessment.service'; 2 | import { handleError } from '@/lib/utils/errors.utils'; 3 | import { type NextRequest } from 'next/dist/server/web/spec-extension/request'; 4 | 5 | export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 6 | try { 7 | const { id } = await params; 8 | const result = await AssessmentService.getAssessmentWithRelations(id); 9 | return Response.json({ data: result }, { status: 200 }); 10 | } catch (err) { 11 | return handleError(err); 12 | } 13 | } 14 | 15 | export async function DELETE( 16 | _request: NextRequest, 17 | { params }: { params: Promise<{ id: string }> } 18 | ) { 19 | try { 20 | const { id } = await params; 21 | const result = await AssessmentService.deleteAssessment(id); 22 | return Response.json({ data: result }, { status: 200 }); 23 | } catch (err) { 24 | return handleError(err); 25 | } 26 | } 27 | 28 | export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 29 | try { 30 | const { id } = await params; 31 | const body = await request.json(); 32 | const parsed = { id, ...body }; 33 | const updatedAssessment = await AssessmentService.updateAssessment(parsed); 34 | return Response.json({ data: updatedAssessment }, { status: 200 }); 35 | } catch (err) { 36 | return handleError(err); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/schemas/position.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const createPositionSchema = z.object({ 4 | title: z 5 | .string() 6 | .min(1, 'Title is required') 7 | .max(200, 'Title must be less than 200 characters') 8 | .trim(), 9 | }); 10 | 11 | export const updatePositionSchema = z.object({ 12 | title: z 13 | .string() 14 | .min(1, 'Title is required') 15 | .max(200, 'Title must be less than 200 characters') 16 | .trim() 17 | .optional(), 18 | }); 19 | 20 | export const getPositionSchema = z.object({ 21 | id: z.string('Invalid position ID'), 22 | }); 23 | 24 | export const deletePositionSchema = z.object({ 25 | id: z.string('Invalid position ID'), 26 | }); 27 | 28 | export const getPositionsByOrgSchema = z.object({ 29 | orgId: z.string('Invalid organization ID'), 30 | }); 31 | 32 | export const positionSchema = z.object({ 33 | id: z.string(), 34 | title: z.string(), 35 | orgId: z.string(), 36 | createdAt: z.date(), 37 | updatedAt: z.date(), 38 | createdById: z.string(), 39 | }); 40 | 41 | export type PositionDTO = z.infer; 42 | export type CreatePositionDTO = z.infer; 43 | export type UpdatePositionDTO = z.infer; 44 | export type GetPositionDTO = z.infer; 45 | export type DeletePositionDTO = z.infer; 46 | export type GetPositionsByOrgDTO = z.infer; 47 | -------------------------------------------------------------------------------- /src/app/api/task-templates/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { handleError } from '@/lib/utils/errors.utils'; 2 | import TaskTemplateService from '@/lib/services/task-template.service'; 3 | import { type NextRequest } from 'next/server'; 4 | import { updateTaskTemplateSchema } from '@/lib/schemas/task-template.schema'; 5 | 6 | export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { 7 | try { 8 | const id = (await params).id; 9 | const result = await TaskTemplateService.getTaskTemplate(id); 10 | return Response.json({ data: result }, { status: 200 }); 11 | } catch (err) { 12 | return handleError(err); 13 | } 14 | } 15 | 16 | export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { 17 | try { 18 | const id = (await params).id; 19 | const result = await TaskTemplateService.deleteTaskTemplate(id); 20 | return Response.json({ data: result }, { status: 200 }); 21 | } catch (err) { 22 | return handleError(err); 23 | } 24 | } 25 | 26 | export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 27 | try { 28 | const id = (await params).id; 29 | const body = await request.json(); 30 | const parsed = updateTaskTemplateSchema.parse({ id, ...body }); 31 | const result = await TaskTemplateService.updateTaskTemplate(parsed); 32 | return Response.json({ data: result }, { status: 200 }); 33 | } catch (err) { 34 | return handleError(err); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/connectors/secrets.connector.ts: -------------------------------------------------------------------------------- 1 | import { SecretsManager } from '@aws-sdk/client-secrets-manager'; 2 | 3 | class SecretsService { 4 | private client: SecretsManager; 5 | private secretName: string; 6 | 7 | constructor() { 8 | this.client = new SecretsManager({ 9 | region: 'us-east-2', 10 | }); 11 | this.secretName = process.env.AWS_SECRET_NAME ?? ''; 12 | } 13 | 14 | async getSecretValue(secret: string): Promise { 15 | try { 16 | const data = await this.client.getSecretValue({ 17 | SecretId: this.secretName, 18 | }); 19 | 20 | if (!data?.SecretString && !data.SecretBinary) { 21 | throw new Error(`Secret ${secret} not found`); 22 | } 23 | 24 | if (data.SecretString) { 25 | const secrets = JSON.parse(data.SecretString); 26 | return secrets[secret]; 27 | } 28 | 29 | if (data.SecretBinary) { 30 | const bufferBinarySecret = Buffer.from(data.SecretBinary); 31 | const decodedBinarySecret = bufferBinarySecret.toString('utf8'); 32 | 33 | const secrets = JSON.parse(decodedBinarySecret); 34 | return secrets[secret]; 35 | } 36 | 37 | return null; 38 | } catch (error) { 39 | // TODO: add a secrets parsing error 40 | throw error; 41 | } 42 | } 43 | } 44 | 45 | const secretsService = new SecretsService(); 46 | export default secretsService; 47 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Inter } from 'next/font/google'; 3 | import localFont from 'next/font/local'; 4 | import './globals.css'; 5 | import { AuthProvider } from '@/lib/auth/auth-context'; 6 | 7 | const inter = Inter({ 8 | variable: '--font-inter', 9 | subsets: ['latin'], 10 | weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'], 11 | }); 12 | 13 | const satoshi = localFont({ 14 | src: [ 15 | { 16 | path: '../lib/fonts/Satoshi-Variable.woff2', 17 | weight: '300 900', 18 | style: 'normal', 19 | }, 20 | { 21 | path: '../lib/fonts/Satoshi-VariableItalic.woff2', 22 | weight: '300 900', 23 | style: 'italic', 24 | }, 25 | ], 26 | variable: '--font-satoshi', 27 | display: 'swap', 28 | fallback: ['inter'], 29 | }); 30 | 31 | export const metadata: Metadata = { 32 | title: 'Sarge', 33 | description: 'Standardized Assessment Review, Grading, & Evaluation', 34 | }; 35 | 36 | export default function RootLayout({ 37 | children, 38 | }: Readonly<{ 39 | children: React.ReactNode; 40 | }>) { 41 | return ( 42 | 43 | 46 | 47 |
{children}
48 |
49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/app/api/position/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import PositionService from '@/lib/services/position.service'; 2 | import { handleError } from '@/lib/utils/errors.utils'; 3 | import { type NextRequest } from 'next/server'; 4 | import { updatePositionSchema } from '@/lib/schemas/position.schema'; 5 | 6 | export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 7 | try { 8 | const positionId = (await params).id; 9 | const result = await PositionService.getPosition(positionId); 10 | return Response.json({ data: result }, { status: 200 }); 11 | } catch (err) { 12 | return handleError(err); 13 | } 14 | } 15 | 16 | export async function DELETE( 17 | _request: NextRequest, 18 | { params }: { params: Promise<{ id: string }> } 19 | ) { 20 | try { 21 | const positionId = (await params).id; 22 | const result = await PositionService.deletePosition(positionId); 23 | return Response.json({ data: result }, { status: 200 }); 24 | } catch (err) { 25 | return handleError(err); 26 | } 27 | } 28 | 29 | export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 30 | try { 31 | const positionId = (await params).id; 32 | const body = await request.json(); 33 | const parsed = updatePositionSchema.parse(body); 34 | const result = await PositionService.updatePosition(positionId, parsed); 35 | return Response.json({ data: result }, { status: 200 }); 36 | } catch (err) { 37 | return handleError(err); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /prisma/teardown.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma'; 2 | 3 | /** 4 | * Teardown script to clean the database 5 | * Truncates all tables and restarts identity sequences 6 | */ 7 | async function main() { 8 | console.log('Starting database teardown...\n'); 9 | 10 | try { 11 | console.log('Truncating all tables...'); 12 | 13 | await prisma.$executeRaw` 14 | TRUNCATE TABLE 15 | "Comment", 16 | "Review", 17 | "Task", 18 | "Assessment", 19 | "TaskTemplate", 20 | "AssessmentTemplate", 21 | "CandidatePoolEntry", 22 | "Candidate", 23 | "Position", 24 | "Tag", 25 | "Member", 26 | "Invitation", 27 | "Session", 28 | "Account", 29 | "Verification", 30 | "User", 31 | "Organization", 32 | "_TaskTemplateTags" 33 | RESTART IDENTITY CASCADE; 34 | `; 35 | 36 | console.log('All tables truncated successfully'); 37 | console.log('Identity sequences restarted'); 38 | console.log('\nDatabase teardown completed successfully!'); 39 | } catch (error) { 40 | console.error('\nTeardown failed:', error); 41 | throw error; 42 | } 43 | } 44 | 45 | main() 46 | .catch((e) => { 47 | console.error('Teardown failed:', e); 48 | process.exit(1); 49 | }) 50 | .finally(async () => { 51 | await prisma.$disconnect(); 52 | }); 53 | -------------------------------------------------------------------------------- /src/app/api/organizations/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from 'next/server'; 2 | import OrganizationService from '@/lib/services/organization.service'; 3 | import { updateOrganizationSchema } from '@/lib/schemas/organization.schema'; 4 | import { handleError } from '@/lib/utils/errors.utils'; 5 | 6 | export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 7 | try { 8 | const orgId = (await params).id; 9 | const result = await OrganizationService.getOrganization(orgId); 10 | return Response.json({ data: result }, { status: 200 }); 11 | } catch (err) { 12 | return handleError(err); 13 | } 14 | } 15 | 16 | export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 17 | try { 18 | const body = await request.json(); 19 | const parsed = updateOrganizationSchema.parse(body); 20 | const orgId = (await params).id; 21 | const result = await OrganizationService.updateOrganization(orgId, parsed); 22 | return Response.json({ data: result }, { status: 200 }); 23 | } catch (err) { 24 | return handleError(err); 25 | } 26 | } 27 | 28 | export async function DELETE( 29 | _request: NextRequest, 30 | { params }: { params: Promise<{ id: string }> } 31 | ) { 32 | try { 33 | const orgId = (await params).id; 34 | const result = await OrganizationService.deleteOrganization(orgId); 35 | return Response.json({ data: result }, { status: 200 }); 36 | } catch (err) { 37 | return handleError(err); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/hooks/useOnboardingState.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { useActiveMember, useSession } from '@/lib/auth/auth-client'; 3 | import { useAuth } from '@/lib/auth/auth-context'; 4 | 5 | type Step = 'welcome' | 'create' | 'join' | null; 6 | 7 | function useOnboardingState() { 8 | const auth = useAuth(); 9 | const { data: session } = useSession(); 10 | const member = useActiveMember(); 11 | 12 | const userId = session?.user?.id ?? null; 13 | 14 | const isUserLoading = auth.sessionPending || member.isPending; 15 | const isSignedOut = !auth.isAuthenticated && !auth.sessionPending; 16 | 17 | const hasOrganization = !!member.data?.organizationId; 18 | const isOnboarding = 19 | auth.isAuthenticated && !auth.hasActiveOrganization && !auth.isPending && !hasOrganization; 20 | 21 | const [step, setStep] = useState(isOnboarding ? 'welcome' : null); 22 | const open = step !== null; 23 | 24 | const onOpenChange = useCallback((open: boolean) => { 25 | if (!open) setStep(null); 26 | }, []); 27 | 28 | const goTo = useCallback((next: Step) => { 29 | setStep(next); 30 | }, []); 31 | 32 | useEffect(() => { 33 | if (isOnboarding && step === null) setStep('welcome'); 34 | if (!isOnboarding && step !== null) setStep(null); 35 | }, [isOnboarding, step]); 36 | 37 | return { 38 | userId, 39 | step, 40 | open, 41 | goTo, 42 | onOpenChange, 43 | isOnboarding, 44 | isSignedOut, 45 | isUserLoading, 46 | }; 47 | } 48 | 49 | export default useOnboardingState; 50 | -------------------------------------------------------------------------------- /src/lib/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { type User } from '@/generated/prisma'; 2 | import { prisma } from '@/lib/prisma'; 3 | import { type UserDTO } from '@/lib/schemas/user.schema'; 4 | import { NotFoundException } from '@/lib/utils/errors.utils'; 5 | 6 | async function deleteUser(userId: string): Promise { 7 | const existingUser = await prisma.user.findUnique({ 8 | where: { id: userId }, 9 | }); 10 | 11 | if (!existingUser) { 12 | throw new NotFoundException('User', userId); 13 | } 14 | 15 | const deleted = await prisma.user.delete({ 16 | where: { 17 | id: userId, 18 | }, 19 | }); 20 | return deleted; 21 | } 22 | 23 | async function getUser(userId: string): Promise { 24 | const user = await prisma.user.findUnique({ 25 | where: { 26 | id: userId, 27 | }, 28 | }); 29 | 30 | if (!user) { 31 | throw new NotFoundException('User', userId); 32 | } 33 | 34 | return user; 35 | } 36 | 37 | async function updateUser(userId: string, userData: Partial): Promise { 38 | const existingUser = await prisma.user.findUnique({ 39 | where: { id: userId }, 40 | }); 41 | 42 | if (!existingUser) { 43 | throw new NotFoundException('User', userId); 44 | } 45 | 46 | const updated = await prisma.user.update({ 47 | where: { 48 | id: userId, 49 | }, 50 | data: { ...userData, updatedAt: new Date() }, 51 | }); 52 | return updated; 53 | } 54 | 55 | const UserService = { 56 | deleteUser, 57 | getUser, 58 | updateUser, 59 | }; 60 | 61 | export default UserService; 62 | -------------------------------------------------------------------------------- /src/app/api/assessment-templates/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { UpdateAssessmentTemplateSchema } from '@/lib/schemas/assessment-template.schema'; 2 | import AssessmentTemplateService from '@/lib/services/assessment-template.service'; 3 | import { handleError } from '@/lib/utils/errors.utils'; 4 | import { type NextRequest } from 'next/server'; 5 | 6 | export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 7 | try { 8 | const { id } = await params; 9 | const template = await AssessmentTemplateService.getAssessmentTemplate(id); 10 | return Response.json({ data: template }, { status: 200 }); 11 | } catch (err) { 12 | return handleError(err); 13 | } 14 | } 15 | 16 | export async function DELETE( 17 | _request: NextRequest, 18 | { params }: { params: Promise<{ id: string }> } 19 | ) { 20 | try { 21 | const { id } = await params; 22 | const result = await AssessmentTemplateService.deleteAssessmentTemplate(id); 23 | return Response.json({ data: result }, { status: 200 }); 24 | } catch (err) { 25 | return handleError(err); 26 | } 27 | } 28 | 29 | export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 30 | try { 31 | const { id } = await params; 32 | const body = await request.json(); 33 | const parsed = UpdateAssessmentTemplateSchema.parse({ id, ...body }); 34 | const updatedTemplate = await AssessmentTemplateService.updateAssessmentTemplate(parsed); 35 | return Response.json({ data: updatedTemplate }, { status: 200 }); 36 | } catch (err) { 37 | return handleError(err); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/components/Sonner.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | CircleCheckIcon, 5 | InfoIcon, 6 | Loader2Icon, 7 | OctagonXIcon, 8 | TriangleAlertIcon, 9 | } from 'lucide-react'; 10 | import { Toaster as Sonner, type ToasterProps } from 'sonner'; 11 | 12 | export function Toaster(props: ToasterProps) { 13 | return ( 14 | , 18 | info: , 19 | warning: , 20 | error: , 21 | loading: , 22 | }} 23 | toastOptions={{ 24 | classNames: { 25 | toast: 'rounded-lg px-3 py-2', 26 | 27 | success: 28 | '!bg-sarge-success-500 !text-sarge-gray-50 !border !border-sarge-success-500', 29 | error: '!bg-sarge-error-400 !text-sarge-gray-50 !border !border-sarge-error-400', 30 | warning: 31 | '!bg-sarge-warning-400 !text-sarge-gray-800 !border !border-sarge-warning-400', 32 | info: '!bg-sarge-gray-200 !text-sarge-gray-800 !border !border-sarge-gray-200', 33 | loading: 34 | '!bg-sarge-gray-200 !text-sarge-gray-800 !border !border-sarge-gray-200', 35 | }, 36 | }} 37 | {...props} 38 | /> 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/app/api/upload/sign/route.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma'; 2 | import s3Service from '@/lib/connectors/s3.connector'; 3 | import { type NextRequest } from 'next/server'; 4 | import { SignBodySchema } from '@/lib/schemas/upload.schema'; 5 | import { handleError, NotFoundException, UnauthorizedException } from '@/lib/utils/errors.utils'; 6 | 7 | export async function POST(request: NextRequest) { 8 | try { 9 | const parsed = SignBodySchema.parse(await request.json()); 10 | 11 | const { type, mime, userId, organizationId } = parsed; 12 | if (!userId) { 13 | throw new UnauthorizedException('User not authenticated'); 14 | } 15 | 16 | if (type === 'organization') { 17 | const organization = await prisma.organization.findFirst({ 18 | where: { 19 | id: organizationId, 20 | }, 21 | }); 22 | 23 | if (!organization) { 24 | throw new NotFoundException('Organization', organizationId); 25 | } 26 | 27 | // const allowed = await isUserAdmin(userId, organizationId); TODO: revist with new permissions system 28 | // if (!allowed) { 29 | // return Response.json(forbidden(`User ${userId} is not authorized`)); 30 | // } 31 | 32 | const res = await s3Service.getSignedURL(type, organizationId, mime); 33 | return Response.json({ data: res }, { status: 200 }); 34 | } 35 | 36 | const res = await s3Service.getSignedURL(type, userId, mime); 37 | return Response.json({ data: res }, { status: 200 }); 38 | } catch (err) { 39 | return handleError(err); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/services/member.service.ts: -------------------------------------------------------------------------------- 1 | import type { Member } from '@/lib/types/member.types'; 2 | import { prisma } from '@/lib/prisma'; 3 | import { ForbiddenException, NotFoundException } from '@/lib/utils/errors.utils'; 4 | import { auth } from '@/lib/auth/auth'; 5 | 6 | async function updateMemberRole( 7 | memberIdToUpdate: string, 8 | role: string, 9 | organizationId: string, 10 | headers: Headers 11 | ): Promise { 12 | const hasPermissionsToUpdate = await auth.api.hasPermission({ 13 | headers, 14 | body: { 15 | permissions: { 16 | member: ['update'], 17 | }, 18 | }, 19 | }); 20 | 21 | if (!hasPermissionsToUpdate) { 22 | throw new ForbiddenException('You are not an admin of this organization'); 23 | } 24 | 25 | const existingMember = await prisma.member.findUnique({ 26 | where: { 27 | id: memberIdToUpdate, 28 | organizationId, 29 | }, 30 | }); 31 | 32 | if (!existingMember) { 33 | throw new NotFoundException('Member', memberIdToUpdate); 34 | } 35 | 36 | const updatedMember = await auth.api.updateMemberRole({ 37 | body: { 38 | role, 39 | memberId: memberIdToUpdate, 40 | organizationId, 41 | }, 42 | }); 43 | 44 | // Can use transformers to convert the response to the Member type if needed 45 | return { 46 | id: updatedMember.id, 47 | organizationId: updatedMember.organizationId, 48 | userId: updatedMember.userId, 49 | role: updatedMember.role, 50 | createdAt: updatedMember.createdAt, 51 | }; 52 | } 53 | 54 | const MemberService = { 55 | updateMemberRole, 56 | }; 57 | 58 | export default MemberService; 59 | -------------------------------------------------------------------------------- /src/app/api/upload/confirm/route.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma'; 2 | import s3Service from '@/lib/connectors/s3.connector'; 3 | import { type NextRequest } from 'next/server'; 4 | import { ConfirmBodySchema } from '@/lib/schemas/upload.schema'; 5 | import { 6 | BadRequestException, 7 | handleError, 8 | InternalServerException, 9 | } from '@/lib/utils/errors.utils'; 10 | 11 | export async function POST(request: NextRequest) { 12 | try { 13 | const parsed = ConfirmBodySchema.parse(await request.json()); 14 | 15 | const { type, key } = parsed; 16 | 17 | const ownerId = type === 'user' ? parsed.userId : parsed.organizationId; 18 | if (!key.startsWith(`${type}/${ownerId}/`)) { 19 | throw new BadRequestException('Key does not match the provided ID'); 20 | } 21 | 22 | const exists = await s3Service.doesKeyExist(key); 23 | if (!exists) throw new BadRequestException('Key does not exist'); 24 | 25 | const cdnBase = process.env.NEXT_PUBLIC_CDN_BASE; 26 | if (!cdnBase) throw new InternalServerException('Could not retrieve CDN URL'); 27 | 28 | const imageUrl = `${cdnBase}/${key}`; 29 | 30 | if (type === 'organization') { 31 | await prisma.organization.update({ 32 | where: { 33 | id: parsed.organizationId, 34 | }, 35 | data: { 36 | logo: imageUrl, 37 | }, 38 | }); 39 | 40 | return Response.json({ data: { imageUrl } }, { status: 200 }); 41 | } 42 | 43 | await prisma.user.update({ 44 | where: { 45 | id: parsed.userId, 46 | }, 47 | data: { 48 | image: imageUrl, 49 | }, 50 | }); 51 | 52 | return Response.json({ data: { imageUrl } }, { status: 200 }); 53 | } catch (error) { 54 | return handleError(error); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/components/Button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { cva, type VariantProps } from 'class-variance-authority'; 5 | import { Slot } from '@radix-ui/react-slot'; 6 | import { cn } from '@/lib/utils/cn.utils'; 7 | 8 | const buttonVariants = cva( 9 | "inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none hover:cursor-pointer disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:h-[20px] [&_svg:not([class*='size-'])]:w-[20px]", 10 | { 11 | variants: { 12 | variant: { 13 | primary: 14 | 'bg-sarge-primary-500 hover:bg-sarge-primary-600 text-sarge-gray-50 [&_svg]:text-sarge-gray-50 transition-colors duration-200 disabled:opacity-50', 15 | secondary: 16 | 'bg-sarge-gray-50 border-sarge-primary-500 hover:bg-sarge-primary-100 text-sarge-primary-500 [&_svg]:text-sarge-primary-500 border transition-colors duration-200 disabled:opacity-50', 17 | tertiary: 18 | 'hover:bg-sarge-primary-100 [&_svg]:text-sarge-primary-500 text-sarge-primary-500 transition-colors duration-200 disabled:opacity-50', 19 | }, 20 | size: { 21 | default: 'gap-[10px] rounded-lg px-1 py-2', 22 | }, 23 | }, 24 | defaultVariants: { 25 | variant: 'primary', 26 | size: 'default', 27 | }, 28 | } 29 | ); 30 | 31 | function Button({ 32 | className, 33 | variant, 34 | size, 35 | asChild = false, 36 | ...props 37 | }: React.ComponentProps<'button'> & 38 | VariantProps & { 39 | asChild?: boolean; 40 | }) { 41 | const Comp = asChild ? Slot : 'button'; 42 | 43 | return ( 44 | 49 | ); 50 | } 51 | 52 | export { Button, buttonVariants }; 53 | -------------------------------------------------------------------------------- /src/lib/connectors/s3.connector.ts: -------------------------------------------------------------------------------- 1 | import { PutObjectCommand, S3Client, NotFound, HeadObjectCommand } from '@aws-sdk/client-s3'; 2 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 3 | import { randomUUID } from 'node:crypto'; 4 | 5 | export type UploadType = 'user' | 'organization'; 6 | 7 | const EXTENSIONS: Record = { 8 | 'image/jpeg': 'jpg', 9 | 'image/png': 'png', 10 | 'image/webp': 'webp', 11 | 'image/gif': 'gif', 12 | 'image/avif': 'avif', 13 | }; 14 | 15 | class S3Service { 16 | private client: S3Client; 17 | private bucket: string; 18 | 19 | constructor() { 20 | this.client = new S3Client({ 21 | region: 'us-east-2', 22 | }); 23 | this.bucket = process.env.AWS_BUCKET_NAME ?? ''; 24 | } 25 | 26 | async getSignedURL( 27 | type: UploadType, 28 | id: string, 29 | mime: string 30 | ): Promise<{ signedURL: string; mime: string; key: string }> { 31 | if (!EXTENSIONS[mime]) throw new Error(`Unsupported mime type: ${mime}`); 32 | const ext = EXTENSIONS[mime]; 33 | 34 | const key = `${type}/${id}/${randomUUID()}.${ext}`; 35 | 36 | const command = new PutObjectCommand({ 37 | Bucket: this.bucket, 38 | Key: key, 39 | ContentType: mime, 40 | }); 41 | 42 | const signedURL = await getSignedUrl(this.client, command, { expiresIn: 15 * 60 }); 43 | 44 | return { signedURL, mime, key }; 45 | } 46 | 47 | async doesKeyExist(key: string): Promise { 48 | try { 49 | const command = new HeadObjectCommand({ 50 | Bucket: this.bucket, 51 | Key: key, 52 | }); 53 | 54 | await this.client.send(command); 55 | return true; 56 | } catch (error) { 57 | if (error instanceof NotFound) { 58 | return false; 59 | } 60 | 61 | throw error; 62 | } 63 | } 64 | } 65 | 66 | const s3Service = new S3Service(); 67 | 68 | export default s3Service; 69 | -------------------------------------------------------------------------------- /src/lib/components/Field.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { cn } from '@/lib/utils/cn.utils'; 5 | 6 | // Field components based on shadcn/ui patterns 7 | const Field = React.forwardRef< 8 | HTMLDivElement, 9 | React.HTMLAttributes & { 'data-invalid'?: boolean } 10 | >(({ className, 'data-invalid': invalid, ...props }, ref) => ( 11 |
17 | )); 18 | Field.displayName = 'Field'; 19 | 20 | const FieldLabel = React.forwardRef>( 21 | ({ className, ...props }, ref) => ( 22 |