Files
heygermany-e2e-mrg3zujp/packages/functions/src/functions/jobs/candidate.functions.ts
e2e 3bb535efe8
Some checks failed
Main / Setup and Test (push) Has been cancelled
Main / Build Website (push) Has been cancelled
chore: heygermany customer project
2026-07-11 10:35:04 +02:00

394 lines
12 KiB
TypeScript

import { pikkuFunc, pikkuSessionlessFunc } from '#pikku/pikku-types.gen.js'
import type { DB } from '@heygermany/sdk'
import {
ApplicantStatuses,
ApplicationCandidateData,
CandidateDocumentTypes,
UserRoles,
UserTypes,
} from '@heygermany/sdk'
import { PickRequired } from '@pikku/core'
import { NotFoundError, ConflictError } from '@pikku/core/errors'
import { isBackOffice, isCandidate } from '../../permissions.js'
import { copyBetterAuthCookies, forwardedAuthHeaders, getWebsiteOrigin } from '../../better-auth.js'
import { toBucketKey } from '../../content-path.js'
import { normalizeStringArray } from '../../normalizers.js'
const createCandidateCredential = () => `${crypto.randomUUID()}-${crypto.randomUUID()}`
export const startFlow = pikkuSessionlessFunc<
{ name: string, email: string },
void
>({
auth: false,
title: 'Start user flow',
description: 'Creates a new user and sets up session',
func: async ({ kysely, auth, config }, { name, email }, { http }) => {
if (!http) {
throw new Error('Candidate signup requires an HTTP request context')
}
const existingAuthUser = await kysely
.selectFrom('authUser')
.select('id')
.where('email', '=', email)
.executeTakeFirst()
if (existingAuthUser) {
throw new ConflictError('Email already exists')
}
const authApi = await auth()
const signUpResponse = await authApi.api.signUpEmail({
body: {
name,
email,
password: createCandidateCredential(),
role: UserRoles.OWNER,
type: UserTypes.CANDIDATE,
},
headers: forwardedAuthHeaders(http),
asResponse: true,
})
if (!signUpResponse.ok) {
if (signUpResponse.status === 409 || signUpResponse.status === 422) {
throw new ConflictError('Email already exists')
}
throw new Error(await signUpResponse.text() || 'Failed to create Better Auth user')
}
const signUpData = await signUpResponse.json() as {
user: {
id: string
}
}
const userId = signUpData.user.id
try {
await kysely.transaction().execute(async (transaction) => {
// Check if candidate with this email already exists
const existingCandidate = await transaction
.selectFrom('candidate')
.select('candidateId')
.where('email', '=', email)
.executeTakeFirst()
if (existingCandidate) {
throw new ConflictError('Email already exists')
}
// Create user first
await transaction
.insertInto('user')
.values({
userId,
name,
email,
role: UserRoles.OWNER,
type: UserTypes.CANDIDATE
})
.execute()
// Create candidate record
await transaction
.insertInto('candidate')
.values({ name, email, candidateId: userId })
.returning('candidateId')
.executeTakeFirstOrThrow()
})
} catch (error) {
await kysely
.deleteFrom('authUser')
.where('id', '=', userId)
.execute()
throw error
}
copyBetterAuthCookies(signUpResponse, http)
await authApi.api.sendVerificationEmail({
body: {
email,
callbackURL: `${getWebsiteOrigin(config.domain)}/verify-email?verified=1`,
},
headers: forwardedAuthHeaders(http),
})
}
})
export const getCandidate = pikkuFunc<
void,
ApplicationCandidateData
>({
permissions: {
isBackOffice,
isCandidate
},
title: 'Get candidate data',
description: 'Fetches candidate form data for the authenticated user',
errors: [NotFoundError],
func: async (_services, _, { session, rpc }) => {
if (!session?.userId) {
throw new NotFoundError('Candidate not found')
}
return await rpc.invoke('getCandidateById', { candidateId: session.userId })
}
})
export const getCandidateById = pikkuFunc<
{ candidateId: string },
ApplicationCandidateData
>({
expose: true,
permissions: {
isBackOffice,
isCandidate
},
func: async ({ kysely }, { candidateId }) => {
const candidate = await kysely
.selectFrom('candidate')
.select(['candidateId', 'dateOfBirth', 'languageCertificateProvided', 'licenseProvided', 'name', 'submittedAt', 'status', 'stateWorkPreference', 'joinTalentPool', 'livingInGermany', 'euPassport', 'countryEducation', 'languageSelfAssessmentLevel', 'languagesSpoken', 'nationality', 'currentResidence', 'professionalRecognitionStatus', 'qualificationStatusGermany', 'recognitionLikelihood'])
.where('candidateId', '=', candidateId)
.executeTakeFirstOrThrow(() => new NotFoundError('Candidate not found'))
// Get documents by category
const documents = await kysely
.selectFrom('candidateDocument')
.selectAll()
.where('candidateId', '=', candidateId)
.execute()
// Group documents by category
const education = documents.filter(d => d.category === CandidateDocumentTypes.EDUCATION)
const licenses = documents.filter(d => d.category === CandidateDocumentTypes.LICENSE)
const languageCertificates = documents.filter(d => d.category === CandidateDocumentTypes.LANGUAGE)
const workExperience = documents.filter(d => d.category === CandidateDocumentTypes.WORK_EXPERIENCE)
return {
...candidate,
stateWorkPreference: normalizeStringArray(candidate.stateWorkPreference),
languagesSpoken: normalizeStringArray(candidate.languagesSpoken),
education,
licenses,
languageCertificates,
workExperience,
}
}
})
export const updateCandidate = pikkuFunc<
PickRequired<Partial<DB.Candidate>, 'candidateId'>,
void
>({
expose: true,
title: 'Update user candidate data',
description: 'Updates candidate form data for the authenticated user',
permissions: {
isBackOffice,
isCandidate
},
func: async ({ kysely }, data) => {
const currentCandidate = await kysely
.selectFrom('candidate')
.select('status')
.where('candidateId', '=', data.candidateId)
.executeTakeFirstOrThrow(() => new NotFoundError())
const shouldResetToInitial =
currentCandidate.status === ApplicantStatuses.INVALID &&
data.status === ApplicantStatuses.INITIAL
const shouldMoveToPending =
currentCandidate.status === ApplicantStatuses.INITIAL &&
!!data.submittedAt
const nextStatus = shouldResetToInitial
? ApplicantStatuses.INITIAL
: shouldMoveToPending
? ApplicantStatuses.PENDING
: undefined
await kysely
.updateTable('candidate')
.set({
stateWorkPreference: data.stateWorkPreference,
joinTalentPool: data.joinTalentPool,
livingInGermany: data.livingInGermany,
euPassport: data.euPassport,
countryEducation: data.countryEducation,
currentResidence: data.currentResidence,
dateOfBirth: data.dateOfBirth,
nationality: data.nationality,
professionalRecognitionStatus: data.professionalRecognitionStatus,
qualificationStatusGermany: data.qualificationStatusGermany,
recognitionLikelihood: data.recognitionLikelihood,
languagesSpoken: data.languagesSpoken,
languageSelfAssessmentLevel: data.languageSelfAssessmentLevel,
submittedAt: shouldResetToInitial ? null : data.submittedAt,
status: nextStatus,
})
.where('candidateId', '=', data.candidateId)
.executeTakeFirstOrThrow(() => new NotFoundError())
}
})
export const addCandidateDocuments = pikkuFunc<
{
candidateId: string,
documents: Array<{
contentType: string
size: number
category: DB.CandidateDocumentType
fileName: string,
}>
},
Array<{
documentId: string
uploadUrl: string
assetKey: string
}>
>({
expose: true,
permissions: {
isBackOffice,
isCandidate
},
title: 'Adds an candidate document that has been uploaded',
func: async ({ kysely, content }, { candidateId, documents }) => {
const documentIds = documents.map(_document => crypto.randomUUID())
const getAssetKey = (category: DB.CandidateDocumentType, documentId: string, contentType: string) =>
`candidate/${candidateId}/${category}/${documentId}.${contentType.split('/')[1]}`
const insertedDocuments = await kysely
.insertInto('candidateDocument')
.values(documents.map((document, index) => {
const { contentType, size, category } = document
return {
candidateId,
documentId: documentIds[index],
mimeType: contentType,
fileSize: size,
category: category as any,
fileName: `${document.fileName}.${contentType.split('/')[1]}`,
filePath: getAssetKey(category as any, documentIds[index], contentType),
}
}))
.returning('documentId')
.execute()
return await Promise.all(documents.map(async (document, index) => {
const { contentType, category } = document
const filePath = getAssetKey(category as any, documentIds[index], contentType)
const { bucket, key } = toBucketKey(filePath)
return {
...await content.getUploadURL({
bucket,
fileKey: key,
contentType,
}),
documentId: insertedDocuments[index].documentId
}
}))
}
})
export const updateCandidateDocument = pikkuFunc<{ candidateId: string, documentId: string, uploaded?: true }, void>({
expose: true,
permissions: {
isBackOffice,
isCandidate
},
title: 'Update candidate document',
description: 'Updates a document uploaded by the candidate and creates entry in relevant table',
func: async ({ kysely }, { candidateId, documentId, uploaded }) => {
await kysely.transaction().execute(async (transaction) => {
if (uploaded) {
const document = await transaction
.updateTable('candidateDocument')
.set('uploaded', uploaded)
.where('candidateDocument.documentId', '=', documentId)
.where('candidateDocument.candidateId', '=', candidateId)
.returning(['category'])
.executeTakeFirstOrThrow(() => new NotFoundError('Document not found'))
const { category } = document
switch (category) {
case CandidateDocumentTypes.EDUCATION:
await transaction
.insertInto('candidateEducation')
.values({
educationId: crypto.randomUUID(),
candidateId,
})
.onConflict((oc) => oc.column('candidateId').doNothing())
.execute()
break
case CandidateDocumentTypes.LICENSE:
await transaction
.insertInto('candidateLicense')
.values({
licenseId: crypto.randomUUID(),
candidateId,
})
.onConflict((oc) => oc.column('candidateId').doNothing())
.execute()
break
case CandidateDocumentTypes.LANGUAGE:
await transaction
.insertInto('candidateLanguage')
.values({
languageId: crypto.randomUUID(),
candidateId,
})
.onConflict((oc) => oc.column('candidateId').doNothing())
.execute()
break
case CandidateDocumentTypes.WORK_EXPERIENCE:
await transaction
.insertInto('candidateExperience')
.values({
experienceId: crypto.randomUUID(),
candidateId,
})
.execute()
break
}
}
})
}
})
export const deleteCandidateDocument = pikkuFunc<{ candidateId: string, documentId: string }, void>({
expose: true,
title: 'Delete candidate document',
description: 'Deletes a document uploaded by the candidate',
permissions: {
isBackOffice,
isCandidate
},
func: async ({ kysely, content, logger }, { candidateId, documentId }) => {
await kysely.transaction().execute(async (transaction) => {
const document = await transaction
.deleteFrom('candidateDocument')
.where('candidateDocument.documentId', '=', documentId)
.where('candidateDocument.candidateId', '=', candidateId)
.returningAll()
.executeTakeFirstOrThrow(() => new NotFoundError('Document not found'))
const deleted = await content.deleteFile(toBucketKey(document.filePath))
if (!deleted) {
logger.error('Failed to delete file from storage', { filePath: document.filePath })
}
})
}
})