394 lines
12 KiB
TypeScript
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 })
|
|
}
|
|
})
|
|
}
|
|
})
|