chore: heygermany customer project
Some checks failed
Main / Setup and Test (push) Has been cancelled
Main / Build Website (push) Has been cancelled

This commit is contained in:
e2e
2026-07-11 10:35:04 +02:00
commit 3bb535efe8
394 changed files with 38048 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
export { QueryClientProvider } from './src/QueryClientProvider'
export { ThemeProvider } from './src/ThemeProvider'

View File

@@ -0,0 +1,37 @@
{
"name": "@heygermany/components",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "index.ts",
"scripts": {
"tsc": "tsc"
},
"dependencies": {
"@heygermany/mantine-theme": "workspace:0.0.0",
"@pikku/mantine": "^0.12.6",
"@pikku/react": "^0.12.5"
},
"peerDependencies": {
"@mantine/core": "^8.2.8",
"@mantine/dates": "^8.3.1",
"@mantine/hooks": "^8.2.8",
"@tanstack/react-query": "^5.85.6",
"dayjs": "^1.11.18",
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@mantine/core": "^8.2.8",
"@mantine/dates": "^8.3.1",
"@mantine/hooks": "^8.2.8",
"@tanstack/react-query": "^5.85.6",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"dayjs": "^1.11.18",
"react": "^19",
"react-dom": "^19",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,21 @@
'use client'
import { QueryClient, QueryClientProvider as TanstackQueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function QueryClientProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: 1,
},
},
}))
return (
<TanstackQueryClientProvider client={queryClient}>
{children}
</TanstackQueryClientProvider>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { MantineProvider, localStorageColorSchemeManager } from '@pikku/mantine/core'
import { DatesProvider } from '@mantine/dates'
import { DAYJS_LOCALE_MAP, theme } from '@heygermany/mantine-theme'
import 'dayjs/locale/en'
import 'dayjs/locale/de'
import 'dayjs/locale/uk'
import 'dayjs/locale/tr'
import 'dayjs/locale/bs'
import 'dayjs/locale/sq'
import 'dayjs/locale/vi'
import 'dayjs/locale/es'
import 'dayjs/locale/hi'
import 'dayjs/locale/ar'
const manager = localStorageColorSchemeManager({ key: 'mantine-color-scheme' })
export function ThemeProvider({
children,
locale = 'en',
}: {
children: React.ReactNode
initial?: 'light' | 'dark' | 'auto'
locale?: string
}) {
const dateLocale = DAYJS_LOCALE_MAP[locale] || 'en'
return (
<MantineProvider
theme={theme}
defaultColorScheme="light"
colorSchemeManager={manager}
>
<DatesProvider settings={{ locale: dateLocale }}>
{children}
</DatesProvider>
</MantineProvider>
)
}

View File

@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"types": ["node"],
"composite": true,
"declaration": true,
"noEmit": false,
"skipLibCheck": true,
"rootDir": ".",
"outDir": "dist",
"jsx": "react-jsx"
},
"references": [
{"path": "../mantine-theme"}
],
"files": [],
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
"exclude": ["dist/**/*", "lib/**/*"]
}

View File

@@ -0,0 +1,78 @@
export const globalBundlePem = `-----BEGIN CERTIFICATE-----
MIICtDCCAjmgAwIBAgIQenQbcP/Zbj9JxvZ+jXbRnTAKBggqhkjOPQQDAzCBmTEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTIwMAYDVQQDDClBbWF6
b24gUkRTIGV1LWNlbnRyYWwtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwH
U2VhdHRsZTAgFw0yMTA1MjEyMjMzMjRaGA8yMTIxMDUyMTIzMzMyNFowgZkxCzAJ
BgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMw
EQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1hem9u
IFJEUyBldS1jZW50cmFsLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATlBHiEM9LoEb1Hdnd5j2VpCDOU
5nGuFoBD8ROUCkFLFh5mHrHfPXwBc63heW9WrP3qnDEm+UZEUvW7ROvtWCTPZdLz
Z4XaqgAlSqeE2VfUyZOZzBSgUUJk7OlznXfkCMOjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFDT/ThjQZl42Nv/4Z/7JYaPNMly2MA4GA1UdDwEB/wQEAwIB
hjAKBggqhkjOPQQDAwNpADBmAjEAnZWmSgpEbmq+oiCa13l5aGmxSlfp9h12Orvw
Dq/W5cENJz891QD0ufOsic5oGq1JAjEAp5kSJj0MxJBTHQze1Aa9gG4sjHBxXn98
4MP1VGsQuhfndNHQb4V0Au7OWnOeiobq
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBTCCAu2gAwIBAgIRAO8bekN7rUReuNPG8pSTKtEwDQYJKoZIhvcNAQELBQAw
gZoxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEzMDEGA1UEAwwq
QW1hem9uIFJEUyBldS1jZW50cmFsLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYD
VQQHDAdTZWF0dGxlMCAXDTIxMDUyMTIyMjM0N1oYDzIwNjEwNTIxMjMyMzQ3WjCB
mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB
bWF6b24gUkRTIGV1LWNlbnRyYWwtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNV
BAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCTTYds
Tray+Q9VA5j5jTh5TunHKFQzn68ZbOzdqaoi/Rq4ohfC0xdLrxCpfqn2TGDHN6Zi
2qGK1tWJZEd1H0trhzd9d1CtGK+3cjabUmz/TjSW/qBar7e9MA67/iJ74Gc+Ww43
A0xPNIWcL4aLrHaLm7sHgAO2UCKsrBUpxErOAACERScVYwPAfu79xeFcX7DmcX+e
lIqY16pQAvK2RIzrekSYfLFxwFq2hnlgKHaVgZ3keKP+nmXcXmRSHQYUUr72oYNZ
HcNYl2+gxCc9ccPEHM7xncVEKmb5cWEWvVoaysgQ+osi5f5aQdzgC2X2g2daKbyA
XL/z5FM9GHpS5BJjAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE
FBDAiJ7Py9/A9etNa/ebOnx5l5MGMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0B
AQsFAAOCAQEALMh/+81fFPdJV/RrJUeoUvFCGMp8iaANu97NpeJyKitNOv7RoeVP
WjivS0KcCqZaDBs+p6IZ0sLI5ZH098LDzzytcfZg0PsGqUAb8a0MiU/LfgDCI9Ee
jsOiwaFB8k0tfUJK32NPcIoQYApTMT2e26lPzYORSkfuntme2PTHUnuC7ikiQrZk
P+SZjWgRuMcp09JfRXyAYWIuix4Gy0eZ4rpRuaTK6mjAb1/LYoNK/iZ/gTeIqrNt
l70OWRsWW8jEmSyNTIubGK/gGGyfuZGSyqoRX6OKHESkP6SSulbIZHyJ5VZkgtXo
2XvyRyJ7w5pFyoofrL3Wv0UF8yt/GDszmg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
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
89cEIGdbjsA=
-----END CERTIFICATE-----
`;
//# sourceMappingURL=global-bundle.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"global-bundle.js","sourceRoot":"","sources":["global-bundle.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4E9B,CAAA"}

View File

@@ -0,0 +1,77 @@
export const globalBundlePem = `-----BEGIN CERTIFICATE-----
MIICtDCCAjmgAwIBAgIQenQbcP/Zbj9JxvZ+jXbRnTAKBggqhkjOPQQDAzCBmTEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTIwMAYDVQQDDClBbWF6
b24gUkRTIGV1LWNlbnRyYWwtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwH
U2VhdHRsZTAgFw0yMTA1MjEyMjMzMjRaGA8yMTIxMDUyMTIzMzMyNFowgZkxCzAJ
BgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMw
EQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1hem9u
IFJEUyBldS1jZW50cmFsLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATlBHiEM9LoEb1Hdnd5j2VpCDOU
5nGuFoBD8ROUCkFLFh5mHrHfPXwBc63heW9WrP3qnDEm+UZEUvW7ROvtWCTPZdLz
Z4XaqgAlSqeE2VfUyZOZzBSgUUJk7OlznXfkCMOjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFDT/ThjQZl42Nv/4Z/7JYaPNMly2MA4GA1UdDwEB/wQEAwIB
hjAKBggqhkjOPQQDAwNpADBmAjEAnZWmSgpEbmq+oiCa13l5aGmxSlfp9h12Orvw
Dq/W5cENJz891QD0ufOsic5oGq1JAjEAp5kSJj0MxJBTHQze1Aa9gG4sjHBxXn98
4MP1VGsQuhfndNHQb4V0Au7OWnOeiobq
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBTCCAu2gAwIBAgIRAO8bekN7rUReuNPG8pSTKtEwDQYJKoZIhvcNAQELBQAw
gZoxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEzMDEGA1UEAwwq
QW1hem9uIFJEUyBldS1jZW50cmFsLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYD
VQQHDAdTZWF0dGxlMCAXDTIxMDUyMTIyMjM0N1oYDzIwNjEwNTIxMjMyMzQ3WjCB
mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB
bWF6b24gUkRTIGV1LWNlbnRyYWwtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNV
BAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCTTYds
Tray+Q9VA5j5jTh5TunHKFQzn68ZbOzdqaoi/Rq4ohfC0xdLrxCpfqn2TGDHN6Zi
2qGK1tWJZEd1H0trhzd9d1CtGK+3cjabUmz/TjSW/qBar7e9MA67/iJ74Gc+Ww43
A0xPNIWcL4aLrHaLm7sHgAO2UCKsrBUpxErOAACERScVYwPAfu79xeFcX7DmcX+e
lIqY16pQAvK2RIzrekSYfLFxwFq2hnlgKHaVgZ3keKP+nmXcXmRSHQYUUr72oYNZ
HcNYl2+gxCc9ccPEHM7xncVEKmb5cWEWvVoaysgQ+osi5f5aQdzgC2X2g2daKbyA
XL/z5FM9GHpS5BJjAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE
FBDAiJ7Py9/A9etNa/ebOnx5l5MGMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0B
AQsFAAOCAQEALMh/+81fFPdJV/RrJUeoUvFCGMp8iaANu97NpeJyKitNOv7RoeVP
WjivS0KcCqZaDBs+p6IZ0sLI5ZH098LDzzytcfZg0PsGqUAb8a0MiU/LfgDCI9Ee
jsOiwaFB8k0tfUJK32NPcIoQYApTMT2e26lPzYORSkfuntme2PTHUnuC7ikiQrZk
P+SZjWgRuMcp09JfRXyAYWIuix4Gy0eZ4rpRuaTK6mjAb1/LYoNK/iZ/gTeIqrNt
l70OWRsWW8jEmSyNTIubGK/gGGyfuZGSyqoRX6OKHESkP6SSulbIZHyJ5VZkgtXo
2XvyRyJ7w5pFyoofrL3Wv0UF8yt/GDszmg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
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
89cEIGdbjsA=
-----END CERTIFICATE-----
`

View File

@@ -0,0 +1,54 @@
{
"name": "@heygermany/functions",
"version": "0.0.0",
"description": "The functions monorepo",
"license": "UNLICENSED",
"private": true,
"type": "module",
"main": "src/index.ts",
"repository": {
"directory": "packages/functions",
"type": "git",
"url": "ssh://git@github.com/pikkujs/yarn-workspace-starter.git"
},
"author": "yasser.fadl@gmail.com",
"scripts": {
"tsc": "tsc",
"ncu": "ncu"
},
"imports": {
"#pikku": "./.pikku/pikku-types.gen.ts",
"#pikku/*.gen.js": "./.pikku/*.gen.ts",
"#pikku/*": "./.pikku/*",
"#types/*": "./types/*"
},
"dependencies": {
"@heygermany/sdk": "^0.0.0",
"@pikku/addon-console": "^0.12.26",
"@pikku/better-auth": "^0.12.16",
"@pikku/core": "^0.12.57",
"@pikku/jose": "^0.12.4",
"@pikku/kysely": "^0.13.0",
"@pikku/kysely-sqlite": "^0.12.8",
"@pikku/pino": "^0.12.2",
"@pikku/schema-cfworker": "^0.12.4",
"@standard-schema/spec": "^1.1.0",
"better-auth": "^1.6.19",
"dayjs": "^1.11.18",
"form-data": "^4.0.4",
"json-logic-js": "^2.0.5",
"kysely": "^0.29.0",
"mailgun.js": "^12.1.0",
"pg": "^8.12.0",
"pino": "^9.3.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@pikku/inspector": "^0.12.38",
"@types/content-type": "^1.1.8",
"@types/json-logic-js": "^2.0.8",
"@types/node": "^22",
"@types/pg": "^8.11.6",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,53 @@
import type {
CoreServices,
CoreServerConfig,
CoreSingletonServices,
CoreUserSession,
EmailService,
LogLevel,
SecretService
} from '@pikku/core'
import type { Kysely } from 'kysely'
import type { UserRole, UserType } from '@heygermany/sdk'
import type { KyselyDB } from './database-types.js'
import type { ContentService } from '@pikku/core/services'
import { SQLConfig } from './config.js'
import { LocalContentConfig } from '@pikku/core/services'
import { RecognitionDossierService } from './services/recognition-dossier-service.js'
import { TranslationService } from './services/translation-service.js'
import type { auth as authFactory } from './wirings/auth.wiring.js'
export interface UserSession extends CoreUserSession {
userId: string
name: string
type: UserType
role: UserRole
}
export interface Config extends CoreServerConfig {
domain: string
port: number
hostname: string
logLevel: LogLevel
sql: SQLConfig
content: LocalContentConfig
secrets: {
mailgunApiKey: string
}
apiKeys: string[]
}
export interface SingletonServices extends CoreSingletonServices<Config> {
kysely: Kysely<KyselyDB>
// Lazy resolver for the Better Auth instance, injected by the generated
// pikkuServices wrapper from the `auth` factory (wirings/auth.wiring.ts).
// `await services.auth()` yields better-auth's server api/handler surface.
auth: () => Promise<Awaited<ReturnType<typeof authFactory>>>
content: ContentService
subscriptionService?: SubscriptionService
emailService: EmailService
recognitionDossierService: RecognitionDossierService
translation: TranslationService
}
export interface Services extends CoreServices<SingletonServices> {}

View File

@@ -0,0 +1,62 @@
import { parseSetCookieHeader } from 'better-auth/cookies'
type HttpContext = {
request?: {
headers(): Record<string, string>
}
response?: {
cookie: (name: string, value: string | null, options: Record<string, unknown>) => void
}
}
const isLocalDomain = (domain: string) => {
return !domain || domain.includes('localhost') || domain.includes('127.0.0.1')
}
/**
* Build a `Headers` carrying the caller's credentials to forward into the Better
* Auth server API (`auth.api.*`). Mirrors Fabric's forwardedAuthHeaders helper.
*/
export const forwardedAuthHeaders = (http?: HttpContext): Headers => {
const src = http?.request?.headers() ?? {}
const headers = new Headers()
if (src.cookie) headers.set('cookie', src.cookie)
if (src.authorization) headers.set('authorization', src.authorization)
return headers
}
/**
* Propagate Better Auth's `Set-Cookie` (e.g. a freshly created session) from a
* server-API `asResponse` result onto the pikku HTTP response.
*/
export const copyBetterAuthCookies = (response: Response, http?: HttpContext) => {
if (!http?.response) {
return
}
const rawSetCookie = response.headers.get('set-cookie')
if (!rawSetCookie) {
return
}
const parsed = parseSetCookieHeader(rawSetCookie)
for (const [name, cookie] of parsed.entries()) {
http.response.cookie(name, cookie.value, {
domain: cookie.domain,
expires: cookie.expires ? new Date(cookie.expires) : undefined,
httpOnly: cookie.httpOnly,
maxAge: cookie.maxAge,
path: cookie.path || '/',
sameSite: cookie.sameSite || 'lax',
secure: cookie.secure,
})
}
}
export const getWebsiteOrigin = (domain: string) => {
if (isLocalDomain(domain)) {
return 'http://localhost:3001'
}
return `https://${domain}`
}

View File

@@ -0,0 +1,41 @@
import { LogLevel } from '@pikku/core/services';
import { globalBundlePem } from '../config/global-bundle.js';
import { pikkuConfig } from '#pikku/pikku-types.gen.js';
import { LOCAL_SQLITE_DB } from './local-db.js';
const { DOMAIN } = process.env;
const requireInProduction = (value, name) => {
if (process.env.NODE_ENV === 'production' && !value) {
throw new Error(`${name} must be configured in production`);
}
return value;
};
export const createConfig = pikkuConfig(async (variablesService) => ({
domain: requireInProduction(DOMAIN, 'DOMAIN') || 'localhost:3001',
sqliteDb: process.env.SQLITE_DB || LOCAL_SQLITE_DB,
port: 4002,
hostname: '0.0.0.0',
logLevel: LogLevel.info,
content: {
localFileUploadPath: process.env.CONTENT_LOCAL_FILE_UPLOAD_PATH || '../../.uploads',
uploadUrlPrefix: process.env.CONTENT_UPLOAD_URL_PREFIX ||
`${(process.env.API_ORIGIN || (DOMAIN ? `https://api.${DOMAIN}` : 'http://localhost:3000')).replace(/\/$/, '')}/reaper/`,
assetUrlPrefix: process.env.CONTENT_ASSET_URL_PREFIX || '/assets',
server: process.env.CONTENT_SERVER ||
process.env.API_ORIGIN ||
(DOMAIN ? `https://api.${DOMAIN}` : 'http://localhost:3000'),
},
secrets: {
mailgunApiKey: 'MAILGUN_API_KEY'
},
sql: {
ssl: variablesService?.get('NODE_ENV') === 'production' ? {
rejectUnauthorized: true,
ca: globalBundlePem
} : undefined
},
apiKeys: process.env.API_KEYS?.split(',')
.map((key) => key.trim())
.filter(Boolean) ||
(process.env.NODE_ENV === 'production' ? [] : ['dev-api-key']),
}));
//# sourceMappingURL=config.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"config.js","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAA;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAM5D,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAA;AACvD,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAS/C,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,GAAG,CAAA;AAE9B,MAAM,mBAAmB,GAAG,CAAC,KAAyB,EAAE,IAAY,EAAE,EAAE;IACtE,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,IAAI,CAAC,KAAK,EAAE,CAAC;QACpD,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,mCAAmC,CAAC,CAAA;IAC7D,CAAC;IAED,OAAO,KAAK,CAAA;AACd,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,YAAY,GAAG,WAAW,CAAC,KAAK,EAAE,gBAAgB,EAAE,EAAE,CAAC,CAAC;IACnE,MAAM,EAAE,mBAAmB,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI,gBAAgB;IACjE,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,eAAe;IAClD,IAAI,EAAE,IAAI;IACV,QAAQ,EAAE,SAAS;IACnB,QAAQ,EAAE,QAAQ,CAAC,IAAI;IACvB,OAAO,EAAE;QACP,mBAAmB,EAAE,OAAO,CAAC,GAAG,CAAC,8BAA8B,IAAI,gBAAgB;QACnF,eAAe,EACb,OAAO,CAAC,GAAG,CAAC,yBAAyB;YACrC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,eAAe,MAAM,EAAE,CAAC,CAAC,CAAC,uBAAuB,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,UAAU;QAC1H,cAAc,EAAE,OAAO,CAAC,GAAG,CAAC,wBAAwB,IAAI,SAAS;QACjE,MAAM,EACJ,OAAO,CAAC,GAAG,CAAC,cAAc;YAC1B,OAAO,CAAC,GAAG,CAAC,UAAU;YACtB,CAAC,MAAM,CAAC,CAAC,CAAC,eAAe,MAAM,EAAE,CAAC,CAAC,CAAC,uBAAuB,CAAC;KAC/D;IACD,OAAO,EAAE;QACP,aAAa,EAAE,iBAAiB;KACjC;IACD,GAAG,EAAE;QACH,GAAG,EAAE,gBAAgB,EAAE,GAAG,CAAC,UAAU,CAAC,KAAK,YAAY,CAAC,CAAC,CAAC;YACxD,kBAAkB,EAAE,IAAI;YACxB,EAAE,EAAE,eAAe;SACpB,CAAC,CAAC,CAAC,SAAS;KACd;IACD,OAAO,EACL,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,GAAG,CAAC;SAC7B,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;SACxB,MAAM,CAAC,OAAO,CAAC;QAClB,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC;CACjE,CAAC,CAAC,CAAA"}

View File

@@ -0,0 +1,59 @@
import { LogLevel } from '@pikku/core/services'
import { globalBundlePem } from '../config/global-bundle.js'
import type {
SecretService,
VariablesService,
} from '@pikku/core/services'
import { pikkuConfig } from '#pikku/pikku-types.gen.js'
import { LOCAL_SQLITE_DB } from './local-db.js'
export interface SQLConfig {
ssl?: {
rejectUnauthorized: boolean
ca: string
}
}
const { DOMAIN } = process.env
const requireInProduction = (value: string | undefined, name: string) => {
if (process.env.NODE_ENV === 'production' && !value) {
throw new Error(`${name} must be configured in production`)
}
return value
}
export const createConfig = pikkuConfig(async (variablesService) => ({
domain: requireInProduction(DOMAIN, 'DOMAIN') || 'localhost:3001',
sqliteDb: process.env.SQLITE_DB || LOCAL_SQLITE_DB,
port: 4002,
hostname: '0.0.0.0',
logLevel: LogLevel.info,
content: {
localFileUploadPath: process.env.CONTENT_LOCAL_FILE_UPLOAD_PATH || '../../.uploads',
uploadUrlPrefix:
process.env.CONTENT_UPLOAD_URL_PREFIX ||
`${(process.env.API_ORIGIN || (DOMAIN ? `https://api.${DOMAIN}` : 'http://localhost:3000')).replace(/\/$/, '')}/reaper/`,
assetUrlPrefix: process.env.CONTENT_ASSET_URL_PREFIX || '/assets',
server:
process.env.CONTENT_SERVER ||
process.env.API_ORIGIN ||
(DOMAIN ? `https://api.${DOMAIN}` : 'http://localhost:3000'),
},
secrets: {
mailgunApiKey: 'MAILGUN_API_KEY'
},
sql: {
ssl: variablesService?.get('NODE_ENV') === 'production' ? {
rejectUnauthorized: true,
ca: globalBundlePem
} : undefined
},
apiKeys:
process.env.API_KEYS?.split(',')
.map((key) => key.trim())
.filter(Boolean) ||
(process.env.NODE_ENV === 'production' ? [] : ['dev-api-key']),
}))

View File

@@ -0,0 +1,8 @@
export const DEFAULT_CONTENT_BUCKET = 'default';
export const toBucketKey = (filePath) => {
return {
bucket: DEFAULT_CONTENT_BUCKET,
key: filePath,
};
};
//# sourceMappingURL=content-path.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"content-path.js","sourceRoot":"","sources":["content-path.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,sBAAsB,GAAG,SAAS,CAAA;AAE/C,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,QAAgB,EAAiB,EAAE;IAC7D,OAAO;QACL,MAAM,EAAE,sBAAsB;QAC9B,GAAG,EAAE,QAAQ;KACd,CAAA;AACH,CAAC,CAAA"}

View File

@@ -0,0 +1,10 @@
import type { BucketKeyArgs } from '@pikku/core/services'
export const DEFAULT_CONTENT_BUCKET = 'default'
export const toBucketKey = (filePath: string): BucketKeyArgs => {
return {
bucket: DEFAULT_CONTENT_BUCKET,
key: filePath,
}
}

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=database-types.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"database-types.js","sourceRoot":"","sources":["database-types.ts"],"names":[],"mappings":""}

View File

@@ -0,0 +1,114 @@
import type { ColumnType } from 'kysely'
import type { DB as SchemaDB } from '../.pikku/db/schema.gen.js'
import type {
CandidateLifecycleNotifications,
DocumentStatus,
GermanState,
LanguageCode,
NurseRecognitionGermany,
RecognitionDossierDocumentCategory,
RecognitionDossierStatus,
} from '@heygermany/sdk'
type AppCandidate = Omit<
SchemaDB['candidate'],
'stateWorkPreference' | 'languagesSpoken' | 'lifecycleNotifications'
> & {
stateWorkPreference: ColumnType<
GermanState[] | null,
GermanState[] | null,
GermanState[] | null
>
languagesSpoken: ColumnType<
LanguageCode[] | null,
LanguageCode[] | null,
LanguageCode[] | null
>
lifecycleNotifications: ColumnType<
CandidateLifecycleNotifications | null,
CandidateLifecycleNotifications | null | undefined,
CandidateLifecycleNotifications | null
>
}
type AppRecognitionDossier = Omit<
SchemaDB['recognitionDossier'],
| 'targetState'
| 'targetProfession'
| 'status'
| 'generatedAt'
| 'submissionGuideJson'
| 'checklistJson'
| 'blockingIssuesJson'
| 'createdAt'
| 'lastUpdatedAt'
> & {
targetState: ColumnType<GermanState, GermanState, GermanState>
targetProfession: ColumnType<
NurseRecognitionGermany,
NurseRecognitionGermany,
NurseRecognitionGermany
>
status: ColumnType<
RecognitionDossierStatus,
RecognitionDossierStatus | undefined,
RecognitionDossierStatus
>
generatedAt: ColumnType<
Date | null,
Date | string | null | undefined,
Date | string | null
>
submissionGuideJson: ColumnType<unknown, unknown | undefined, unknown>
checklistJson: ColumnType<unknown, unknown | undefined, unknown>
blockingIssuesJson: ColumnType<unknown, unknown | undefined, unknown>
createdAt: ColumnType<Date, Date | string | undefined, Date | string>
lastUpdatedAt: ColumnType<Date, Date | string | undefined, Date | string>
}
type AppRecognitionDossierDocument = Omit<
SchemaDB['recognitionDossierDocument'],
'category' | 'uploaded' | 'documentStatus' | 'createdAt' | 'lastUpdatedAt'
> & {
category: ColumnType<
RecognitionDossierDocumentCategory,
RecognitionDossierDocumentCategory,
RecognitionDossierDocumentCategory
>
uploaded: ColumnType<boolean, boolean | undefined, boolean>
documentStatus: ColumnType<
DocumentStatus,
DocumentStatus | undefined,
DocumentStatus
>
createdAt: ColumnType<Date, Date | string | undefined, Date | string>
lastUpdatedAt: ColumnType<Date, Date | string | undefined, Date | string>
}
interface BackendTranslation {
locale: ColumnType<string, string, string>
namespace: ColumnType<'cards' | 'misc' | 'outputs', 'cards' | 'misc' | 'outputs', 'cards' | 'misc' | 'outputs'>
contentJson: ColumnType<unknown, unknown, unknown>
createdAt: ColumnType<Date, Date | string | undefined, Date | string>
updatedAt: ColumnType<Date, Date | string | undefined, Date | string>
}
export interface KyselyDB {
authAccount: SchemaDB['authAccount']
authSession: SchemaDB['authSession']
authUser: SchemaDB['authUser']
authVerification: SchemaDB['authVerification']
backendTranslation: BackendTranslation
candidate: AppCandidate
candidateDocument: SchemaDB['candidateDocument']
candidateEducation: SchemaDB['candidateEducation']
candidateExperience: SchemaDB['candidateExperience']
candidateLanguage: SchemaDB['candidateLanguage']
candidateLicense: SchemaDB['candidateLicense']
countries: SchemaDB['countries']
leads: SchemaDB['leads']
recognitionDossier: AppRecognitionDossier
recognitionDossierDocument: AppRecognitionDossierDocument
user: SchemaDB['user']
userAuth: SchemaDB['userAuth']
}

View File

@@ -0,0 +1,186 @@
import type { DB } from '@heygermany/sdk'
import {
DocumentStatuses,
LanguageLevels,
NurseRecognitionGermanyValues,
type GetCandidateResult,
type QualificationCard,
} from '@heygermany/sdk'
import type { TranslationService } from "../services/translation-service.js"
export const createCandidateCardEngine = (translation: TranslationService, locale: string) => ({
constants: {},
// ============= EDUCATION =============
education: {
// Completed if all 3 are true; otherwise Required.
rules: [
{
id: "education_completed",
when: {
and: [
{ "==": [{ var: "nursingRelatedDegree" }, true] },
{ "==": [{ var: "accreditedUniversity" }, true] },
{ "==": [{ var: "structuredTraining.overallStatus" }, true] },
],
},
status: "completed",
},
{
id: "education_required",
when: { "!!": true }, // fallback
status: "required",
},
],
// Next step priority: 1 > 3 > 2 (show only one)
pickNextStep(candidate: GetCandidateResult): string | undefined {
const missDegree = candidate.nursingRelatedDegree === false
const missStructured = candidate.structuredTraining.overallStatus === false
const missAccredited = candidate.accreditedUniversity === false
if (missDegree) {
return translation.t('education.steps.missdegree', locale)
}
if (missStructured) {
return translation.t('education.steps.missstructured', locale)
}
if (missAccredited) {
return translation.t('education.steps.missaccredited', locale)
}
return undefined
},
getRequirements(): string[] {
return translation.tArray('education.requirements', locale)
},
getCompletedDescription(): string {
return translation.t('education.completeddescription', locale)
},
getRequiredDescription(): string {
return translation.t('education.requireddescription', locale)
},
},
// ============= LANGUAGE =============
language: {
build(candidate: GetCandidateResult): QualificationCard {
const LEVEL_RANK: Record<DB.LanguageLevel, number> = {
A_ONE: 1, A_TWO: 2, B_ONE: 3, B_TWO: 4, C_ONE: 5, C_TWO: 6, NONE: 0
}
const REQUIRED_GERMAN_BY_ROLE: Record<DB.NurseRecognitionGermany, DB.LanguageLevel> = {
[NurseRecognitionGermanyValues.NURSE]: LanguageLevels.B_TWO,
[NurseRecognitionGermanyValues.ASSISTANT_NURSE]: LanguageLevels.B_ONE,
[NurseRecognitionGermanyValues.NURSING_HELPER]: LanguageLevels.B_ONE,
}
const reqLevel = REQUIRED_GERMAN_BY_ROLE[candidate.qualificationStatusGermany!]
const req = LEVEL_RANK[reqLevel]
const skills = LEVEL_RANK[candidate.languageLevel!]
const meets = skills >= req
const certUploaded = candidate.docs.language === DocumentStatuses.VERIFIED
const status: QualificationCard['status'] = meets ? "completed" : "in-progress"
// progress: requirement == 100%, skills are a share of 100
const progress = !meets ? Math.max(0, Math.min(100, Math.round((skills / req) * 100))) : undefined
const requirements = [
translation.t('language.requirements.germanlevel', locale, { level: reqLevel }),
translation.t('language.requirements.certificate', locale),
]
// Steps:
// - If not met: always show both step 1 and 2 together.
// - If met but no certificate uploaded (i.e., self-assessment only), show the reminder text instead of steps.
const steps: string[] | undefined = !meets
? [
translation.t('language.steps.improvelevel', locale, {
currentLevel: candidate.languageLevel || translation.t('language.levelnames.none', locale),
requiredLevel: reqLevel
}),
translation.t('language.steps.getcertificate', locale),
]
: undefined
const description = translation.t('language.description', locale)
// Reminder when met via self-assessment, without uploaded certificate
const reminderWhenMetNoCert =
meets && !certUploaded
? translation.t('language.reminder', locale)
: undefined
return {
title: translation.t('language.title', locale),
description,
status,
progress,
requirements,
steps: steps ?? (reminderWhenMetNoCert ? [reminderWhenMetNoCert] : undefined),
}
},
},
// ============= LICENSE =============
license: {
// Completed if license doc uploaded OR license not needed (licenseMandatory = false)
rules: [
{
id: "license_completed",
when: { "or": [
{ "==": [{ var: "licenseMandatory" }, false] },
{ "==": [{ var: "docs.license" }, DocumentStatuses.VERIFIED] }
]},
status: "completed",
},
{
id: "license_required",
when: { "!!": true },
status: "required",
},
],
getDescriptionCompleted(): string {
return translation.t('license.descriptioncompleted', locale)
},
getDescriptionRequired(): string {
return translation.t('license.descriptionrequired', locale)
},
getRequirements(): string[] {
return translation.tArray('license.requirements', locale)
},
getSteps(): string[] {
return translation.tArray('license.steps', locale)
},
},
// ============= ADDITIONAL (Experience + Copies + Originals) =============
additional: {
experience(candidate: GetCandidateResult): QualificationCard {
const cvUploaded = candidate.docs.cv === DocumentStatuses.VERIFIED
return {
title: translation.t('additional.experience.title', locale),
description: translation.t('additional.experience.description', locale),
status: cvUploaded ? "completed" : "optional",
requirements: translation.tArray('additional.experience.requirements', locale),
steps: cvUploaded ? undefined : translation.tArray('additional.experience.steps', locale),
}
},
copies(): QualificationCard {
return {
title: translation.t('additional.copies.title', locale),
description: translation.t('additional.copies.description', locale),
status: "required",
requirements: translation.tArray('additional.copies.requirements', locale),
steps: translation.tArray('additional.copies.steps', locale),
}
},
originals(): QualificationCard {
return {
title: translation.t('additional.originals.title', locale),
description: translation.t('additional.originals.description', locale),
status: "required",
requirements: translation.tArray('additional.originals.requirements', locale),
steps: translation.tArray('additional.originals.steps', locale),
}
},
},
})

View File

@@ -0,0 +1,225 @@
import type { DB, GetCandidateResult } from '@heygermany/sdk'
import {
DocumentStatuses,
LanguageLevels,
NurseRecognitionGermanyValues,
} from '@heygermany/sdk'
import type { TranslationService } from "../services/translation-service.js"
export const createCandidateTextEngine = (translation: TranslationService, locale: string) => ({
version: 1,
// ============= RULES (inputs) =============
rules: [
// Nurse — all met
{
id: "nurse_allMet",
when: {
and: [
{ "==": [{ var: "qualificationStatusGermany" }, NurseRecognitionGermanyValues.NURSE] },
{ "==": [{ var: "nursingRelatedDegree" }, true] },
{ "==": [{ var: "structuredTraining.overallStatus" }, true] },
{ "==": [{ var: "accreditedUniversity" }, true] },
{ "or": [
{ "==": [{ var: "licenseMandatory" }, false] },
{ "==": [{ var: "docs.license" }, DocumentStatuses.VERIFIED] }
]}
]
},
render: "nurse.allMet"
},
// Nurse — missing license (accreditation OK)
{
id: "nurse_missingLicense",
when: {
and: [
{ "==": [{ var: "qualificationStatusGermany" }, NurseRecognitionGermanyValues.NURSE] },
{ "==": [{ var: "licenseMandatory" }, true] },
{ "!=": [{ var: "docs.license" }, DocumentStatuses.VERIFIED] },
{ "==": [{ var: "accreditedUniversity" }, true] }
]
},
render: "nurse.missingLicense"
},
// Nurse — missing license + accreditation
{
id: "nurse_missingLicenseAndAccreditation",
when: {
and: [
{ "==": [{ var: "qualificationStatusGermany" }, NurseRecognitionGermanyValues.NURSE] },
{ "==": [{ var: "licenseMandatory" }, true] },
{ "!=": [{ var: "docs.license" }, DocumentStatuses.VERIFIED] },
{ "==": [{ var: "accreditedUniversity" }, false] }
]
},
render: "nurse.missingLicenseAndAccreditation"
},
// Assistant Nurse — all met
{
id: "assistant_allMet",
when: {
and: [
{ "==": [{ var: "qualificationStatusGermany" }, NurseRecognitionGermanyValues.ASSISTANT_NURSE] },
{ "or": [
{ "==": [{ var: "licenseMandatory" }, false] },
{ "==": [{ var: "docs.license" }, DocumentStatuses.VERIFIED] }
]},
{ "==": [{ var: "nursingRelatedDegree" }, true] },
{ "==": [{ var: "structuredTraining.overallStatus" }, true] },
{ "==": [{ var: "accreditedUniversity" }, true] }
]
},
render: "assistant.allMet"
},
// Assistant Nurse — missing license
{
id: "assistant_missingLicense",
when: {
and: [
{ "==": [{ var: "qualificationStatusGermany" }, NurseRecognitionGermanyValues.ASSISTANT_NURSE] },
{ "==": [{ var: "licenseMandatory" }, true] },
{ "!=": [{ var: "docs.license" }, DocumentStatuses.VERIFIED] },
{ "==": [{ var: "accreditedUniversity" }, true] }
]
},
render: "assistant.missingLicense"
},
// Assistant Nurse — missing license + accreditation
{
id: "assistant_missingLicenseAndAccreditation",
when: {
and: [
{ "==": [{ var: "qualificationStatusGermany" }, NurseRecognitionGermanyValues.ASSISTANT_NURSE] },
{ "==": [{ var: "licenseMandatory" }, true] },
{ "!=": [{ var: "docs.license" }, DocumentStatuses.VERIFIED] },
{ "==": [{ var: "accreditedUniversity" }, false] }
]
},
render: "assistant.missingLicenseAndAccreditation"
},
// Nursing Helper — default
{
id: "helper_default",
when: { "==": [{ var: "qualificationStatusGermany" }, NurseRecognitionGermanyValues.NURSING_HELPER] },
render: "helper.default"
}
],
// ============= GLOBAL NOTES/ADD-ONS (inputs) =============
notes: [
{
id: "visa_note",
when: {
and: [
{ "==": [{ var: "euPassport" }, false] },
{ "==": [{ var: "livingInGermany" }, false] },
{
and: [
{ "!=": [{ var: "languageLevel" }, LanguageLevels.B_TWO] },
{ "!=": [{ var: "languageLevel" }, LanguageLevels.C_ONE] },
{ "!=": [{ var: "languageLevel" }, LanguageLevels.C_TWO] }
]
}
]
},
textKey: "notes.visanote",
attach: { where: "afterExplanation" } // your renderer decides how to place it
}
],
// ============= OUTPUT TEMPLATES (copy) =============
getOutputs() {
return translation.get(locale)
},
// ============= NEXT STEPS =============
getNextSteps(renderPath: string | null, candidate: GetCandidateResult) {
const allNextSteps = translation.get(locale)
// Get default next steps (comes from nextsteps.json -> merged into locale data)
const defaultSteps = allNextSteps?.default
if (!defaultSteps) {
console.error('[candidate-text-engine] No default next steps found in locale.default')
return null
}
// Helper function to deep merge step objects
const deepMergeSteps = (base: any, override: any) => {
const result = { ...base }
for (const [stepKey, stepOverride] of Object.entries(override)) {
if (result[stepKey] && typeof stepOverride === 'object') {
// Merge properties within the step
result[stepKey] = { ...result[stepKey], ...stepOverride }
} else {
result[stepKey] = stepOverride
}
}
return result
}
// Start with default
let steps = { ...defaultSteps }
// Check if there's a role-level override (e.g., "assistant.default", "nurse.default")
if (renderPath) {
const roleName = renderPath.split('.')[0] // Extract "nurse", "assistant", "helper"
const roleDefault = allNextSteps?.[`${roleName}.default`]
if (roleDefault) {
// Deep merge role-level defaults on top of global defaults
steps = deepMergeSteps(steps, roleDefault)
}
// Check if there's a pathway-specific override (e.g., "assistant.missinglicense")
const pathwaySteps = allNextSteps?.[renderPath.toLowerCase()]
if (pathwaySteps) {
// Deep merge pathway-specific overrides on top
steps = deepMergeSteps(steps, pathwaySteps)
}
}
// Conditionally include german step based on language requirements
const LEVEL_RANK: Record<DB.LanguageLevel, number> = {
A_ONE: 1, A_TWO: 2, B_ONE: 3, B_TWO: 4, C_ONE: 5, C_TWO: 6, NONE: 0
}
const LEVEL_DISPLAY: Record<DB.LanguageLevel, string> = {
A_ONE: 'A1', A_TWO: 'A2', B_ONE: 'B1', B_TWO: 'B2', C_ONE: 'C1', C_TWO: 'C2', NONE: 'beginner'
}
const REQUIRED_GERMAN_BY_ROLE: Record<DB.NurseRecognitionGermany, DB.LanguageLevel> = {
[NurseRecognitionGermanyValues.NURSE]: LanguageLevels.B_TWO,
[NurseRecognitionGermanyValues.ASSISTANT_NURSE]: LanguageLevels.B_ONE,
[NurseRecognitionGermanyValues.NURSING_HELPER]: LanguageLevels.B_ONE,
}
const reqLevel = REQUIRED_GERMAN_BY_ROLE[candidate.qualificationStatusGermany!]
const currentLevel = candidate.languageLevel || LanguageLevels.NONE
const hasMetLanguageRequirement = LEVEL_RANK[currentLevel] >= LEVEL_RANK[reqLevel]
// Remove german step if language requirement is already met
if (hasMetLanguageRequirement) {
const { german, ...stepsWithoutGerman } = steps
return stepsWithoutGerman
}
// Interpolate language levels in german step subtitle
if (steps.german) {
steps = {
...steps,
german: {
...steps.german,
subtitle: steps.german.subtitle
.replace('{currentLevel}', LEVEL_DISPLAY[currentLevel])
.replace('{requiredLevel}', LEVEL_DISPLAY[reqLevel])
}
}
}
return steps
}
})

View File

@@ -0,0 +1,2 @@
export { createCandidateCardEngine } from './candidate-card-engine.js'
export { createCandidateTextEngine } from './candidate-text-engine.js'

View File

@@ -0,0 +1,150 @@
/**
* Auto-generated translation token types
* @generated - DO NOT EDIT MANUALLY
*/
export type TranslationTokens =
| 'additional.copies.description'
| 'additional.copies.requirements[0]'
| 'additional.copies.requirements[1]'
| 'additional.copies.requirements[2]'
| 'additional.copies.requirements[3]'
| 'additional.copies.steps[0]'
| 'additional.copies.steps[1]'
| 'additional.copies.title'
| 'additional.experience.description'
| 'additional.experience.requirements[0]'
| 'additional.experience.steps[0]'
| 'additional.experience.steps[1]'
| 'additional.experience.title'
| 'additional.originals.description'
| 'additional.originals.requirements[0]'
| 'additional.originals.requirements[1]'
| 'additional.originals.requirements[2]'
| 'additional.originals.requirements[3]'
| 'additional.originals.steps[0]'
| 'additional.originals.steps[1]'
| 'additional.originals.title'
| 'assistant.allmet.explanation[0]'
| 'assistant.allmet.explanation[1]'
| 'assistant.allmet.hero'
| 'assistant.allmet.nextsteps[0]'
| 'assistant.allmet.nextsteps[1]'
| 'assistant.allmet.recognition[0]'
| 'assistant.allmet.recognition[1]'
| 'assistant.allmet.recognition[2]'
| 'assistant.allmet.result'
| 'assistant.allmet.submit.subtitle'
| 'assistant.missing.submit.subtitle'
| 'assistant.missinglicense.explanation[0]'
| 'assistant.missinglicense.explanation[1]'
| 'assistant.missinglicense.hero'
| 'assistant.missinglicense.nextsteps[0]'
| 'assistant.missinglicense.nextsteps[1]'
| 'assistant.missinglicense.recognition[0]'
| 'assistant.missinglicense.recognition[1]'
| 'assistant.missinglicense.recognition[2]'
| 'assistant.missinglicense.result'
| 'assistant.missinglicenseandaccreditation.explanation[0]'
| 'assistant.missinglicenseandaccreditation.explanation[1]'
| 'assistant.missinglicenseandaccreditation.explanation[2]'
| 'assistant.missinglicenseandaccreditation.hero'
| 'assistant.missinglicenseandaccreditation.nextsteps[0]'
| 'assistant.missinglicenseandaccreditation.nextsteps[1]'
| 'assistant.missinglicenseandaccreditation.recognition[0]'
| 'assistant.missinglicenseandaccreditation.recognition[1]'
| 'assistant.missinglicenseandaccreditation.recognition[2]'
| 'assistant.missinglicenseandaccreditation.result'
| 'default.compensatory.button'
| 'default.compensatory.costs'
| 'default.compensatory.subtitle'
| 'default.compensatory.title'
| 'default.connect.buttonimprove'
| 'default.connect.buttonjoin'
| 'default.connect.costs'
| 'default.connect.subtitle'
| 'default.connect.title'
| 'default.documents.button'
| 'default.documents.costs'
| 'default.documents.subtitle'
| 'default.documents.title'
| 'default.german.button'
| 'default.german.costs'
| 'default.german.subtitle'
| 'default.german.title'
| 'default.gethelp.button'
| 'default.gethelp.costs'
| 'default.gethelp.subtitle'
| 'default.gethelp.title'
| 'default.submit.button'
| 'default.submit.costs'
| 'default.submit.subtitle'
| 'default.submit.title'
| 'education.completeddescription'
| 'education.requireddescription'
| 'education.requirements[0]'
| 'education.requirements[1]'
| 'education.requirements[2]'
| 'education.steps.missaccredited'
| 'education.steps.missdegree'
| 'education.steps.missstructured'
| 'education.title'
| 'helper.default.explanation[0]'
| 'helper.default.explanation[1]'
| 'helper.default.hero'
| 'helper.default.nextsteps[0]'
| 'helper.default.nextsteps[1]'
| 'helper.default.nextsteps[2]'
| 'helper.default.result'
| 'helper_allmet.explanation[0]'
| 'helper_allmet.explanation[1]'
| 'helper_allmet.hero'
| 'helper_allmet.nextsteps[0]'
| 'helper_allmet.nextsteps[1]'
| 'helper_allmetnextsteps[2]'
| 'language.description'
| 'language.levelnames.none'
| 'language.reminder'
| 'language.requirements.certificate'
| 'language.requirements.germanlevel'
| 'language.steps.getcertificate'
| 'language.steps.improvelevel'
| 'language.title'
| 'license.descriptioncompleted'
| 'license.descriptionrequired'
| 'license.requirements[0]'
| 'license.steps[0]'
| 'license.title'
| 'notes.visanote'
| 'nurse.allmet.explanation[0]'
| 'nurse.allmet.explanation[1]'
| 'nurse.allmet.hero'
| 'nurse.allmet.nextsteps[0]'
| 'nurse.allmet.recognition[0]'
| 'nurse.allmet.recognition[1]'
| 'nurse.allmet.recognition[2]'
| 'nurse.allmet.result'
| 'nurse.missinglicense.explanation[0]'
| 'nurse.missinglicense.explanation[1]'
| 'nurse.missinglicense.hero'
| 'nurse.missinglicense.nextsteps[0]'
| 'nurse.missinglicense.recognition[0]'
| 'nurse.missinglicense.recognition[1]'
| 'nurse.missinglicense.recognition[2]'
| 'nurse.missinglicense.recognition[3]'
| 'nurse.missinglicense.result'
| 'nurse.missinglicenseandaccreditation.explanation[0]'
| 'nurse.missinglicenseandaccreditation.explanation[1]'
| 'nurse.missinglicenseandaccreditation.explanation[2]'
| 'nurse.missinglicenseandaccreditation.hero'
| 'nurse.missinglicenseandaccreditation.nextsteps[0]'
| 'nurse.missinglicenseandaccreditation.recognition[0]'
| 'nurse.missinglicenseandaccreditation.recognition[1]'
| 'nurse.missinglicenseandaccreditation.recognition[2]'
| 'nurse.missinglicenseandaccreditation.recognition[3]'
| 'nurse.missinglicenseandaccreditation.result'
/**
* Alias for backwards compatibility
*/
export type Tokens = TranslationTokens

View File

@@ -0,0 +1,9 @@
import { addError, PikkuError } from "@pikku/core/errors";
export class AlreadyVotedError extends PikkuError {
constructor() {
super('User already voted on this todo')
}
}
addError(AlreadyVotedError, { status: 409, message: 'User already voted' })

View File

@@ -0,0 +1,406 @@
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import type { DB } from '@heygermany/sdk'
import {
ApplicantStatuses,
BackOfficeCandidate,
GetBackOfficeCandidatesOutput,
BackOfficeCandidateListItem,
CandidateDocumentTypes,
CandidateLifecycleNotifications,
} from '@heygermany/sdk'
import { NotFoundError } from '@pikku/core/errors'
import dayjs from 'dayjs'
import { isBackOffice } from '../../permissions.js'
import { forwardedAuthHeaders } from '../../better-auth.js'
import { toBucketKey } from '../../content-path.js'
import { normalizeStringArray } from '../../normalizers.js'
const omitLifecycleNotifications = <T extends Record<string, unknown>>(candidate: T): Omit<T, 'lifecycleNotifications'> => {
const {
lifecycleNotifications: _lifecycleNotifications,
...candidateWithoutLifecycleNotifications
} = candidate as T & { lifecycleNotifications?: unknown }
return candidateWithoutLifecycleNotifications as Omit<T, 'lifecycleNotifications'>
}
export const getBackOfficeSignedCandidateDocument = pikkuFunc<
{ candidateId: string, documentId: string },
{ url: string }
>({
expose: true,
permissions: { isBackOffice },
title: 'Get signed candidate document',
description: 'Fetches the signed URL for a candidate document',
func: async ({ kysely, content }, { candidateId, documentId }) => {
const document = await kysely
.selectFrom('candidateDocument')
.selectAll()
.where('documentId', '=', documentId)
.where('candidateId', '=', candidateId)
.executeTakeFirstOrThrow(() => new NotFoundError('Document not found'))
const { bucket, key } = toBucketKey(document.filePath)
return {
url: await content.signContentKey({
bucket,
contentKey: key,
dateLessThan: dayjs().add(15, 'minutes').toDate(),
})
}
}
})
export const getBackOfficeCandidates = pikkuFunc<
{ offset?: number; limit?: number },
GetBackOfficeCandidatesOutput
>({
expose: true,
permissions: { isBackOffice },
title: 'Get all candidates',
description: 'Fetches paginated list of candidates (candidates) with total count',
func: async ({ kysely }) => {
const { count } = await kysely
.selectFrom('candidate')
.select(({ fn }) => fn.countAll().as('count'))
.executeTakeFirstOrThrow()
const candidates = await kysely
.selectFrom('candidate')
.selectAll()
.orderBy('createdAt', 'desc')
.execute()
const data = candidates.map(omitLifecycleNotifications)
return {
total: Number(count),
data: data.map((candidate) => ({
...candidate,
stateWorkPreference: normalizeStringArray(candidate.stateWorkPreference),
languagesSpoken: normalizeStringArray(candidate.languagesSpoken),
})),
}
}
})
export const getBackOfficeCandidate = pikkuFunc<
{ candidateId: string },
BackOfficeCandidate
>({
expose: true,
permissions: { isBackOffice },
title: 'Get single candidate by ID',
description: 'Fetches a specific candidate with all their structured document data',
errors: [NotFoundError],
func: async ({ kysely }, { candidateId }) => {
const candidate = await kysely
.selectFrom('candidate')
.selectAll()
.where('candidateId', '=', candidateId)
.executeTakeFirstOrThrow(() => new NotFoundError('Candidate not found'))
// Fetch candidate documents once and group by category for verification.
const candidateDocuments = await kysely
.selectFrom('candidateDocument')
.selectAll()
.where('candidateId', '=', candidateId)
.execute()
const educationDocuments = candidateDocuments.filter((document) => document.category === CandidateDocumentTypes.EDUCATION)
const licenseDocuments = candidateDocuments.filter((document) => document.category === CandidateDocumentTypes.LICENSE)
const languageDocuments = candidateDocuments.filter((document) => document.category === CandidateDocumentTypes.LANGUAGE)
const experienceDocuments = candidateDocuments.filter((document) => document.category === CandidateDocumentTypes.WORK_EXPERIENCE)
// Fetch one analysis record per candidate/category.
const education = await kysely
.selectFrom('candidateEducation')
.selectAll('candidateEducation')
.where('candidateEducation.candidateId', '=', candidateId)
.executeTakeFirst()
const license = await kysely
.selectFrom('candidateLicense')
.selectAll('candidateLicense')
.where('candidateLicense.candidateId', '=', candidateId)
.executeTakeFirst()
const language = await kysely
.selectFrom('candidateLanguage')
.selectAll('candidateLanguage')
.where('candidateLanguage.candidateId', '=', candidateId)
.executeTakeFirst()
const experience = await kysely
.selectFrom('candidateExperience')
.selectAll('candidateExperience')
.where('candidateExperience.candidateId', '=', candidateId)
.orderBy('candidateExperience.createdAt', 'desc')
.executeTakeFirst()
const backOfficeCandidate = omitLifecycleNotifications(candidate)
const lifecycleNotifications =
(candidate as typeof candidate & {
lifecycleNotifications?: CandidateLifecycleNotifications | null
}).lifecycleNotifications ?? null
return {
...backOfficeCandidate,
stateWorkPreference: normalizeStringArray(backOfficeCandidate.stateWorkPreference),
languagesSpoken: normalizeStringArray(backOfficeCandidate.languagesSpoken),
lifecycleNotifications,
analysis: {
education: education || null,
license: license || null,
language: language || null,
experience: experience || null,
},
documents: {
education: educationDocuments,
license: licenseDocuments,
language: languageDocuments,
experience: experienceDocuments,
}
}
}
})
export const updateBackOfficeCandidate = pikkuFunc<
{ candidateId: string; analysisIssues?: string[] } & Partial<Pick<DB.Candidate, 'qualificationStatusHomeCountry' | 'qualificationStatusGermany' | 'licenseMandatory' | 'durationRequirementsMet' | 'status' | 'recognitionLikelihood' | 'dateOfBirth'>>,
void
>({
expose: true,
permissions: { isBackOffice },
title: 'Update candidate qualification status',
description: 'Updates the qualification status fields for a candidate',
errors: [NotFoundError],
func: async ({ kysely, auth }, { candidateId, analysisIssues, ...updateData }, { http }) => {
if (Object.keys(updateData).length === 0 && analysisIssues === undefined) {
return
}
const currentCandidate = await kysely
.selectFrom('candidate')
.select(['candidateId', 'status', 'email', 'name', 'lifecycleNotifications'])
.where('candidateId', '=', candidateId)
.executeTakeFirstOrThrow(() => new NotFoundError('Candidate not found'))
if (updateData.status && currentCandidate.status !== updateData.status && currentCandidate.email && currentCandidate.name) {
// Backoffice-driven email notifications are intentionally limited to Complete only.
// Invalid (and other lifecycle states) are handled by lifecycle rake tasks in BE.
const shouldSendEmail =
updateData.status === ApplicantStatuses.COMPLETE
if (shouldSendEmail) {
if (updateData.status === ApplicantStatuses.COMPLETE) {
const authApi = await auth()
await authApi.api.signInMagicLink({
headers: forwardedAuthHeaders(http),
body: {
email: currentCandidate.email,
name: currentCandidate.name,
callbackURL: '/jobs/application',
metadata: {
template: 'candidate-qualified',
},
},
})
}
}
}
if (Object.keys(updateData).length > 0) {
await kysely
.updateTable('candidate')
.set(updateData)
.where('candidateId', '=', candidateId)
.executeTakeFirstOrThrow(() => new NotFoundError('Candidate not found'))
}
if (analysisIssues !== undefined) {
const normalizedIssues = analysisIssues
.map((issue) => issue?.trim())
.filter((issue): issue is string => Boolean(issue))
const lifecycleNotifications =
(currentCandidate.lifecycleNotifications &&
typeof currentCandidate.lifecycleNotifications === 'object' &&
!Array.isArray(currentCandidate.lifecycleNotifications))
? currentCandidate.lifecycleNotifications
: null
const existingAnalysis =
lifecycleNotifications?.analysis &&
typeof lifecycleNotifications.analysis === 'object' &&
!Array.isArray(lifecycleNotifications.analysis)
? lifecycleNotifications.analysis
: null
await kysely
.updateTable('candidate')
.set({
lifecycleNotifications: {
...(lifecycleNotifications ?? {}),
analysis: {
...(existingAnalysis ?? {}),
issues: normalizedIssues,
updated_at: new Date().toISOString(),
},
},
})
.where('candidateId', '=', candidateId)
.executeTakeFirstOrThrow(() => new NotFoundError('Candidate not found'))
}
}
})
export const updateBackOfficeCandidateDocument = pikkuFunc<
{ candidateId: string; documentId: string; documentStatus: DB.DocumentStatus },
{ success: boolean }
>({
expose: true,
permissions: { isBackOffice },
title: 'Update candidate document status',
description: 'Updates the candidate document status',
errors: [NotFoundError],
func: async ({ kysely }, { candidateId, documentId, documentStatus }) => {
await kysely
.updateTable('candidateDocument')
.set({ documentStatus })
.where('documentId', '=', documentId)
.where('candidateId', '=', candidateId)
.executeTakeFirstOrThrow(() => new NotFoundError('Document not found'))
return { success: true }
}
})
export const updateBackOfficeCandidateEducation = pikkuFunc<
{
candidateId: string
} & Partial<Omit<DB.CandidateEducation, 'candidateId' | 'educationId' | 'createdAt' | 'lastUpdatedAt'>>,
void
>({
expose: true,
permissions: { isBackOffice },
title: 'Update candidate education',
description: 'Upserts the candidate education record',
errors: [NotFoundError],
func: async ({ kysely }, { candidateId, ...updateData }) => {
if (Object.keys(updateData).length === 0) {
return
}
await kysely
.insertInto('candidateEducation')
.values({
educationId: crypto.randomUUID(),
candidateId,
...updateData
})
.onConflict((oc) => oc.column('candidateId').doUpdateSet(updateData))
.execute()
}
})
export const updateBackOfficeCandidateLicense = pikkuFunc<
{
candidateId: string
} & Partial<Omit<DB.CandidateLicense, 'candidateId' | 'licenseId' | 'createdAt' | 'lastUpdatedAt'>>,
void
>({
expose: true,
permissions: { isBackOffice },
title: 'Update candidate license',
description: 'Upserts the candidate license record',
errors: [NotFoundError],
func: async ({ kysely }, { candidateId, ...updateData }) => {
if (Object.keys(updateData).length === 0) {
return
}
await kysely
.insertInto('candidateLicense')
.values({
licenseId: crypto.randomUUID(),
candidateId,
...updateData
})
.onConflict((oc) => oc.column('candidateId').doUpdateSet(updateData))
.execute()
}
})
export const updateBackOfficeCandidateLanguage = pikkuFunc<
{
candidateId: string
} & Partial<Omit<DB.CandidateLanguage, 'candidateId' | 'languageId' | 'createdAt' | 'lastUpdatedAt'>>,
void
>({
expose: true,
permissions: { isBackOffice },
title: 'Update candidate language',
description: 'Upserts the candidate language record',
errors: [NotFoundError],
func: async ({ kysely }, { candidateId, ...updateData }) => {
if (Object.keys(updateData).length === 0) {
return
}
await kysely
.insertInto('candidateLanguage')
.values({
languageId: crypto.randomUUID(),
candidateId,
...updateData
})
.onConflict((oc) => oc.column('candidateId').doUpdateSet(updateData))
.execute()
}
})
export const updateBackOfficeCandidateExperience = pikkuFunc<
{
candidateId: string
} & Partial<Omit<DB.CandidateExperience, 'candidateId' | 'experienceId' | 'createdAt' | 'lastUpdatedAt'>>,
void
>({
expose: true,
permissions: { isBackOffice },
title: 'Update candidate experience',
description: 'Upserts the candidate experience record',
errors: [NotFoundError],
func: async ({ kysely }, { candidateId, ...updateData }) => {
if (Object.keys(updateData).length === 0) {
return
}
const existingExperience = await kysely
.selectFrom('candidateExperience')
.select('experienceId')
.where('candidateId', '=', candidateId)
.orderBy('createdAt', 'desc')
.executeTakeFirst()
if (existingExperience) {
await kysely
.updateTable('candidateExperience')
.set({
...updateData,
lastUpdatedAt: new Date(),
})
.where('experienceId', '=', existingExperience.experienceId)
.execute()
return
}
await kysely
.insertInto('candidateExperience')
.values({
experienceId: crypto.randomUUID(),
candidateId,
...updateData,
})
.execute()
}
})

View File

@@ -0,0 +1,63 @@
import { wireHTTP } from '#pikku/pikku-types.gen.js'
import { getCandidateResult } from '../jobs/candidate-results.functions.js'
import { getBackOfficeCandidates, getBackOfficeCandidate, getBackOfficeSignedCandidateDocument, updateBackOfficeCandidate, updateBackOfficeCandidateDocument, updateBackOfficeCandidateEducation, updateBackOfficeCandidateLicense, updateBackOfficeCandidateLanguage, updateBackOfficeCandidateExperience } from './candidate.function.js'
wireHTTP({
method: 'get',
route: '/backoffice/candidate',
func: getBackOfficeCandidates,
})
wireHTTP({
method: 'get',
route: '/backoffice/candidate/:candidateId',
func: getBackOfficeCandidate,
})
wireHTTP({
method: 'get',
route: '/backoffice/candidate/:candidateId/document/:documentId',
func: getBackOfficeSignedCandidateDocument,
})
wireHTTP({
method: 'patch',
route: '/backoffice/candidate/:candidateId',
func: updateBackOfficeCandidate,
})
wireHTTP({
method: 'patch',
route: '/backoffice/candidate/:candidateId/document/:documentId',
func: updateBackOfficeCandidateDocument,
})
wireHTTP({
method: 'patch',
route: '/backoffice/candidate/:candidateId/education',
func: updateBackOfficeCandidateEducation,
})
wireHTTP({
method: 'patch',
route: '/backoffice/candidate/:candidateId/license',
func: updateBackOfficeCandidateLicense,
})
wireHTTP({
method: 'patch',
route: '/backoffice/candidate/:candidateId/language',
func: updateBackOfficeCandidateLanguage,
})
wireHTTP({
method: 'patch',
route: '/backoffice/candidate/:candidateId/experience',
func: updateBackOfficeCandidateExperience,
})
wireHTTP({
method: 'get',
route: '/backoffice/candidate/:candidateId/result',
func: getCandidateResult,
})

View File

@@ -0,0 +1,153 @@
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import type {
DB,
RecognitionDossierDetail,
RecognitionDossierSummary,
} from '@heygermany/sdk'
import { BadRequestError, NotFoundError } from '@pikku/core/errors'
import { isBackOffice } from '../../permissions.js'
export const getBackOfficeRecognitionDossiers = pikkuFunc<
{ candidateId: string },
RecognitionDossierSummary[]
>({
expose: true,
permissions: { isBackOffice },
title: 'Get recognition dossiers',
description: 'Fetches all recognition dossiers for a candidate',
errors: [NotFoundError],
func: async ({ recognitionDossierService }, { candidateId }) =>
await recognitionDossierService.listCandidateDossiers(candidateId),
})
export const getBackOfficeRecognitionDossier = pikkuFunc<
{ candidateId: string; dossierId: string },
RecognitionDossierDetail
>({
expose: true,
permissions: { isBackOffice },
title: 'Get recognition dossier',
description: 'Fetches one recognition dossier with checklist, documents, and download state',
errors: [NotFoundError],
func: async ({ recognitionDossierService }, { candidateId, dossierId }) =>
await recognitionDossierService.getCandidateDossier(candidateId, dossierId),
})
export const createBackOfficeRecognitionDossier = pikkuFunc<
{
candidateId: string
targetState: DB.GermanState
targetProfession: DB.NurseRecognitionGermany
},
RecognitionDossierDetail
>({
expose: true,
permissions: { isBackOffice },
title: 'Create recognition dossier',
description: 'Creates or refreshes the state-specific recognition dossier for a candidate',
errors: [NotFoundError],
func: async (
{ recognitionDossierService },
{ candidateId, targetState, targetProfession }
) =>
await recognitionDossierService.createOrRefreshDossier({
candidateId,
targetState,
targetProfession,
}),
})
export const requestBackOfficeRecognitionDossierDocumentUploads = pikkuFunc<
{
candidateId: string
dossierId: string
documents: Array<{
contentType: string
size: number
category: DB.RecognitionDossierDocumentCategory
fileName: string
}>
},
Array<{
documentId: string
uploadUrl: string
assetKey: string
uploadHeaders?: Record<string, string>
uploadMethod?: 'PUT' | 'POST'
}>
>({
expose: true,
permissions: { isBackOffice },
title: 'Request recognition dossier document uploads',
description: 'Creates dossier document records and returns signed upload URLs',
errors: [BadRequestError, NotFoundError],
func: async ({ recognitionDossierService }, input) =>
await recognitionDossierService.requestDocumentUploads(input),
})
export const completeBackOfficeRecognitionDossierDocumentUpload = pikkuFunc<
{
candidateId: string
dossierId: string
documentId: string
uploaded?: true
},
RecognitionDossierDetail
>({
expose: true,
permissions: { isBackOffice },
title: 'Complete recognition dossier document upload',
description: 'Marks a dossier document as uploaded and refreshes dossier readiness',
errors: [NotFoundError],
func: async ({ recognitionDossierService }, { candidateId, dossierId, documentId, uploaded }) =>
await recognitionDossierService.completeDocumentUpload({
candidateId,
dossierId,
documentId,
uploaded: uploaded === true,
}),
})
export const deleteBackOfficeRecognitionDossierDocument = pikkuFunc<
{
candidateId: string
dossierId: string
documentId: string
},
RecognitionDossierDetail
>({
expose: true,
permissions: { isBackOffice },
title: 'Delete recognition dossier document',
description: 'Deletes a dossier-only document and refreshes dossier readiness',
errors: [NotFoundError],
func: async ({ recognitionDossierService }, input) =>
await recognitionDossierService.deleteDocument(input),
})
export const generateBackOfficeRecognitionDossierPackage = pikkuFunc<
{ candidateId: string; dossierId: string },
RecognitionDossierDetail
>({
expose: true,
permissions: { isBackOffice },
title: 'Generate recognition dossier package',
description: 'Builds and stores the recognition dossier package when the dossier is ready',
errors: [BadRequestError, NotFoundError],
func: async ({ recognitionDossierService }, input) =>
await recognitionDossierService.generatePackage(input),
})
export const getBackOfficeRecognitionDossierDownload = pikkuFunc<
{ candidateId: string; dossierId: string },
{ url: string }
>({
expose: true,
permissions: { isBackOffice },
title: 'Get recognition dossier download URL',
description: 'Fetches a signed download URL for the generated dossier package',
errors: [BadRequestError, NotFoundError],
func: async ({ recognitionDossierService }, input) => ({
url: await recognitionDossierService.getDownloadUrl(input),
}),
})

View File

@@ -0,0 +1,99 @@
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import type {
BackendTranslationBundle,
BackendTranslationNamespace,
} from '@heygermany/sdk'
import { isBackOffice } from '../../permissions.js'
type BackendTranslationRow = {
locale: string
namespace: BackendTranslationNamespace
contentJson: unknown
updatedAt: Date
}
function parseContent(value: unknown): Record<string, unknown> {
if (typeof value === 'string') {
return parseContent(JSON.parse(value))
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
export const getBackOfficeTranslations = pikkuFunc<
{ locale?: string; namespace?: BackendTranslationNamespace },
BackendTranslationBundle[]
>({
expose: true,
permissions: { isBackOffice },
title: 'Get backend translations',
description: 'Fetches DB-backed backend/result translations for backoffice editing',
func: async ({ kysely, translation }, input) => {
await translation.init()
let query = kysely
.selectFrom('backendTranslation')
.select(['locale', 'namespace', 'contentJson', 'updatedAt'])
.orderBy('locale')
.orderBy('namespace')
if (input.locale) {
query = query.where('locale', '=', input.locale)
}
if (input.namespace) {
query = query.where('namespace', '=', input.namespace)
}
const rows = await query.execute() as BackendTranslationRow[]
return rows.map((row) => ({
locale: row.locale,
namespace: row.namespace,
content: parseContent(row.contentJson),
updatedAt: row.updatedAt,
}))
},
})
export const updateBackOfficeTranslation = pikkuFunc<
{
locale: string
namespace: BackendTranslationNamespace
content: Record<string, unknown>
},
BackendTranslationBundle
>({
expose: true,
permissions: { isBackOffice },
title: 'Update backend translation bundle',
description: 'Updates a DB-backed backend/result translation bundle',
func: async ({ kysely }, input) => {
await kysely
.updateTable('backendTranslation')
.set({
contentJson: JSON.stringify(input.content),
updatedAt: new Date(),
})
.where('locale', '=', input.locale)
.where('namespace', '=', input.namespace)
.execute()
const row = await kysely
.selectFrom('backendTranslation')
.select(['locale', 'namespace', 'contentJson', 'updatedAt'])
.where('locale', '=', input.locale)
.where('namespace', '=', input.namespace)
.executeTakeFirstOrThrow() as BackendTranslationRow
return {
locale: row.locale,
namespace: row.namespace,
content: parseContent(row.contentJson),
updatedAt: row.updatedAt,
}
},
})

View File

@@ -0,0 +1,462 @@
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { NotFoundError } from '@pikku/core/errors'
import type {
DB,
GetCompanyCandidatesOutput,
PublicCandidateInfo,
} from '@heygermany/sdk'
import {
ApplicantStatuses,
CandidateDocumentTypes,
DocumentStatuses,
} from '@heygermany/sdk'
import dayjs from 'dayjs'
import { Kysely, sql } from 'kysely'
import type { KyselyDB } from '../../database-types.js'
import { isBackOffice, isCompany } from '../../permissions.js'
import { toBucketKey } from '../../content-path.js'
import { normalizeStringArray } from '../../normalizers.js'
const PROFILE_IMAGE_CATEGORY = 'profile_image'
type ProfileImageSigningServices = {
content: {
signContentKey: (args: {
bucket: string
contentKey: string
dateLessThan: Date
}) => Promise<string>
}
kysely: Kysely<KyselyDB>
logger: {
warn: (...args: any[]) => void
}
}
// Helper function to calculate age from date of birth
const calculateAge = (dateOfBirth: Date | null): number | null => {
if (!dateOfBirth) return null
const today = new Date()
const birthDate = new Date(dateOfBirth)
let age = today.getFullYear() - birthDate.getFullYear()
const monthDiff = today.getMonth() - birthDate.getMonth()
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < birthDate.getDate())
) {
age--
}
return age
}
const getSignedProfileImageUrls = async (
{ kysely, content, logger }: ProfileImageSigningServices,
candidateIds: string[]
) => {
if (candidateIds.length === 0) {
return new Map<string, string>()
}
const profileImages = await kysely
.selectFrom('candidateDocument')
.select(['candidateId', 'documentId', 'filePath'])
.where('candidateId', 'in', candidateIds)
.where('category', '=', PROFILE_IMAGE_CATEGORY as any)
.where('uploaded', '=', true)
.orderBy('lastUpdatedAt', 'desc')
.execute()
const urlEntries = await Promise.all(
profileImages.map(async (document) => {
try {
const { bucket, key } = toBucketKey(document.filePath)
return [
document.candidateId,
await content.signContentKey({
bucket,
contentKey: key,
dateLessThan: dayjs().add(15, 'minutes').toDate(),
}),
] as const
} catch (error) {
logger.warn(
{
candidateId: document.candidateId,
documentId: document.documentId,
error,
},
'Failed to sign candidate profile image'
)
return null
}
})
)
const profileImageUrls = new Map<string, string>()
for (const entry of urlEntries) {
if (!entry) continue
const [candidateId, url] = entry
if (!profileImageUrls.has(candidateId)) {
profileImageUrls.set(candidateId, url)
}
}
return profileImageUrls
}
const getExperienceMonthIndex = (
date: Date | string | null | undefined
): number | null => {
if (!date) return null
const parsedDate = new Date(date)
if (Number.isNaN(parsedDate.getTime())) return null
return parsedDate.getUTCFullYear() * 12 + parsedDate.getUTCMonth()
}
const calculateTotalExperienceMonths = (
experienceEntries: Array<{
startDate: Date | string | null
endDate: Date | string | null
}>
) => {
const normalizedRanges = experienceEntries
.map(({ startDate, endDate }) => {
const startMonth = getExperienceMonthIndex(startDate)
const endMonth = getExperienceMonthIndex(endDate || new Date())
if (
startMonth === null ||
endMonth === null ||
endMonth < startMonth
) {
return null
}
return { startMonth, endMonth }
})
.filter((range): range is { startMonth: number; endMonth: number } =>
Boolean(range)
)
.sort((a, b) => a.startMonth - b.startMonth)
if (normalizedRanges.length === 0) {
return null
}
const mergedRanges = [normalizedRanges[0]]
for (const range of normalizedRanges.slice(1)) {
const lastRange = mergedRanges[mergedRanges.length - 1]
if (range.startMonth <= lastRange.endMonth + 1) {
lastRange.endMonth = Math.max(lastRange.endMonth, range.endMonth)
continue
}
mergedRanges.push({ ...range })
}
return mergedRanges.reduce(
(total, range) => total + (range.endMonth - range.startMonth + 1),
0
)
}
const getPublicLanguageFields = ({
languageAnalysis,
languageCertificateProvided,
languageSelfAssessmentLevel,
}: {
languageAnalysis?: {
level: DB.LanguageLevel | null
} | null
languageCertificateProvided: boolean | null
languageSelfAssessmentLevel: DB.LanguageLevel | null
}) => ({
languageCertificateProvided:
languageAnalysis ? true : languageCertificateProvided,
languageSelfAssessmentLevel:
languageAnalysis?.level ?? languageSelfAssessmentLevel,
})
export const getCompanyCandidates = pikkuFunc<
{ offset?: number; limit?: number },
GetCompanyCandidatesOutput
>({
expose: true,
title: 'Get all candidates (public info)',
description:
'Fetches paginated list of candidates with non-personal information only',
permissions: {
isBackOffice,
isCompany,
},
func: async ({ kysely, content, logger }) => {
const { count } = await kysely
.selectFrom('candidate')
.select(({ fn }) => fn.countAll().as('count'))
.executeTakeFirstOrThrow()
const candidates = await kysely
.selectFrom('candidate')
.select([
'candidateId',
'name',
'submittedAt',
'stateWorkPreference',
'joinTalentPool',
'livingInGermany',
'euPassport',
'countryEducation',
'languageCertificateProvided',
'languageSelfAssessmentLevel',
'qualificationStatusGermany',
'qualificationStatusHomeCountry',
'currentResidence',
'createdAt',
'languagesSpoken',
'nationality',
'professionalRecognitionStatus',
'recognitionLikelihood',
'dateOfBirth',
])
.where('candidate.status', '=', ApplicantStatuses.COMPLETE)
.orderBy('createdAt', 'desc')
.execute()
const candidateIds = candidates.map(({ candidateId }) => candidateId)
const educationAnalyses =
candidateIds.length > 0
? await kysely
.selectFrom('candidateEducation')
.select([
'candidateId',
'institution',
'program',
'country',
'completionDate',
'nursingRelatedDegree',
])
.where('candidateId', 'in', candidateIds)
.execute()
: []
const educationByCandidateId = new Map(
educationAnalyses.map(({ candidateId, ...education }) => [
candidateId,
education,
])
)
const languageAnalyses =
candidateIds.length > 0
? await kysely
.selectFrom('candidateLanguage')
.select(['candidateId', 'level'])
.where('candidateId', 'in', candidateIds)
.execute()
: []
const languageByCandidateId = new Map(
languageAnalyses.map(({ candidateId, ...languageAnalysis }) => [
candidateId,
languageAnalysis,
])
)
const experienceAnalyses =
candidateIds.length > 0
? await kysely
.selectFrom('candidateExperience')
.select(['candidateId', 'startDate', 'endDate'])
.where('candidateId', 'in', candidateIds)
.execute()
: []
const experienceByCandidateId = new Map<
string,
Array<{
startDate: Date | null
endDate: Date | null
}>
>()
for (const experience of experienceAnalyses) {
const candidateExperiences =
experienceByCandidateId.get(experience.candidateId) || []
candidateExperiences.push({
startDate: experience.startDate,
endDate: experience.endDate,
})
experienceByCandidateId.set(experience.candidateId, candidateExperiences)
}
const profileImageUrls = await getSignedProfileImageUrls(
{ kysely, content, logger },
candidateIds
)
const data: PublicCandidateInfo[] = candidates.map(
({ dateOfBirth, ...candidate }) => {
const publicLanguageFields = getPublicLanguageFields({
languageAnalysis: languageByCandidateId.get(candidate.candidateId),
languageCertificateProvided: candidate.languageCertificateProvided,
languageSelfAssessmentLevel: candidate.languageSelfAssessmentLevel,
})
return {
...candidate,
stateWorkPreference: normalizeStringArray(candidate.stateWorkPreference),
languagesSpoken: normalizeStringArray(candidate.languagesSpoken),
...publicLanguageFields,
age: calculateAge(dateOfBirth),
hasVerifiedLicense: false,
profileImageUrl: profileImageUrls.get(candidate.candidateId) || null,
totalExperienceMonths: calculateTotalExperienceMonths(
experienceByCandidateId.get(candidate.candidateId) || []
),
experience: [],
education: educationByCandidateId.get(candidate.candidateId) || null,
}
}
)
return {
total: Number(count),
data,
}
},
})
export const getCompanyCandidate = pikkuFunc<
{ candidateId: string },
PublicCandidateInfo
>({
expose: true,
title: 'Get single candidate by ID (public info)',
description:
'Fetches a specific candidate with non-personal information only',
errors: [NotFoundError],
permissions: {
isBackOffice,
isCompany,
},
func: async ({ kysely, content, logger }, { candidateId }) => {
const { dateOfBirth, ...candidate } = await kysely
.selectFrom('candidate')
.select([
'candidateId',
'name',
'submittedAt',
'stateWorkPreference',
'joinTalentPool',
'livingInGermany',
'euPassport',
'countryEducation',
'languageCertificateProvided',
'languageSelfAssessmentLevel',
'qualificationStatusGermany',
'qualificationStatusHomeCountry',
'currentResidence',
'createdAt',
'languagesSpoken',
'nationality',
'professionalRecognitionStatus',
'recognitionLikelihood',
'dateOfBirth',
])
.where('candidateId', '=', candidateId)
.executeTakeFirstOrThrow(() => new NotFoundError('Candidate not found'))
// Check if candidate has a verified license document.
const verifiedLicenseDocument = await kysely
.selectFrom('candidateDocument')
.select('documentId')
.where('candidateId', '=', candidateId)
.where('category', '=', CandidateDocumentTypes.LICENSE)
.where('documentStatus', '=', DocumentStatuses.VERIFIED)
.executeTakeFirst()
const educationAnalysis = await kysely
.selectFrom('candidateEducation')
.select([
'institution',
'program',
'country',
'completionDate',
'nursingRelatedDegree',
])
.where('candidateEducation.candidateId', '=', candidateId)
.executeTakeFirst()
const languageAnalysis = await kysely
.selectFrom('candidateLanguage')
.select(['level'])
.where('candidateLanguage.candidateId', '=', candidateId)
.executeTakeFirst()
const experienceAnalysis = await kysely
.selectFrom('candidateExperience')
.select([
sql<string | null>`${sql.ref(
'candidate_experience.employer_name'
)}`.as(
'employerName'
),
sql<string | null>`${sql.ref('candidate_experience.role_title')}`.as(
'roleTitle'
),
'candidateExperience.department',
sql<string | null>`${sql.ref('candidate_experience.country')}`.as(
'country'
),
sql<string | null>`${sql.ref('candidate_experience.city')}`.as('city'),
sql<string | null>`${sql.ref(
'candidate_experience.facility_type'
)}`.as(
'facilityType'
),
'candidateExperience.startDate',
'candidateExperience.endDate',
])
.where('candidateExperience.candidateId', '=', candidateId)
.orderBy('candidateExperience.startDate', 'desc')
.orderBy('candidateExperience.createdAt', 'desc')
.execute()
const profileImageUrls = await getSignedProfileImageUrls(
{ kysely, content, logger },
[candidateId]
)
const publicLanguageFields = getPublicLanguageFields({
languageAnalysis,
languageCertificateProvided: candidate.languageCertificateProvided,
languageSelfAssessmentLevel: candidate.languageSelfAssessmentLevel,
})
return {
...candidate,
stateWorkPreference: normalizeStringArray(candidate.stateWorkPreference),
languagesSpoken: normalizeStringArray(candidate.languagesSpoken),
...publicLanguageFields,
age: calculateAge(dateOfBirth),
hasVerifiedLicense: !!verifiedLicenseDocument,
profileImageUrl: profileImageUrls.get(candidateId) || null,
totalExperienceMonths: calculateTotalExperienceMonths(experienceAnalysis),
experience: experienceAnalysis.map((experience) => ({
employerName: experience.employerName,
roleTitle: experience.roleTitle,
department: experience.department,
country: experience.country,
city: experience.city,
facilityType: experience.facilityType,
startDate: experience.startDate,
endDate: experience.endDate,
})),
education: educationAnalysis || null,
}
},
})

View File

@@ -0,0 +1,18 @@
import { wireHTTP } from '#pikku/pikku-types.gen.js'
import { isCompany } from '../../permissions.js'
import { getCompanyCandidates, getCompanyCandidate } from './candidate.function.js'
wireHTTP({
method: 'get',
route: '/company/candidate',
func: getCompanyCandidates,
permissions: {
isCompany
}
})
wireHTTP({
method: 'get',
route: '/company/candidate/:candidateId',
func: getCompanyCandidate
})

View File

@@ -0,0 +1,47 @@
import { pikkuSessionlessFunc } from '#pikku/pikku-types.gen.js'
import { sql } from 'kysely'
import { CountriesMap } from '@heygermany/sdk'
const localeToColumn: Record<string, string> = {
'en': 'name',
'de': 'name_de',
'es': 'name_es',
'fr': 'name_fr',
'it': 'name_it',
'pl': 'name_pl',
'pt': 'name_pt',
'ru': 'name_ru',
'uk': 'name_uk',
'tr': 'name_tr',
'ja': 'name_ja',
'ko': 'name_ko',
'nl': 'name_nl',
'hr': 'name_hr',
'fa': 'name_fa',
}
export const getCountries = pikkuSessionlessFunc<
{ locale?: string },
CountriesMap
>({
auth: false,
title: 'Get countries list',
description: 'Returns countries map with ISO3 codes as keys and localized names as values',
func: async ({ kysely }, { locale }) => {
const column = localeToColumn[locale || 'en'] || 'name'
const results = await kysely
.selectFrom('countries')
.select([
'iso3',
sql`COALESCE(${sql.ref(column)}, name)`.as('name')
])
.orderBy(sql`COALESCE(${sql.ref(column)}, name)`, 'asc')
.execute()
return results.reduce((acc, row) => {
acc[row.iso3] = row.name as string
return acc
}, {} as CountriesMap)
}
})

View File

@@ -0,0 +1,8 @@
import { wireHTTP } from '#pikku/pikku-types.gen.js'
import { getCountries } from './countries.functions.js'
wireHTTP({
method: 'get',
route: '/countries',
func: getCountries,
})

View File

@@ -0,0 +1,10 @@
import { pikkuSessionlessFunc } from '#pikku/pikku-types.gen.js'
export const healthCheck = pikkuSessionlessFunc<null, { ok: true }>({
auth: false,
title: 'Health check',
description: 'Returns a simple readiness payload for local and Fabric smoke checks',
func: async () => ({
ok: true,
}),
})

View File

@@ -0,0 +1,9 @@
import { wireHTTP } from '#pikku/pikku-types.gen.js'
import { healthCheck } from './health-check.functions.js'
wireHTTP({
method: 'get',
route: '/health-check',
func: healthCheck,
auth: false,
})

View File

@@ -0,0 +1,64 @@
import { pikkuSessionlessFunc } from '#pikku/pikku-types.gen.js'
import { sql } from 'kysely'
export const getInstitutionMatches = pikkuSessionlessFunc<
{ query: string },
any
>(async ({ kysely }, { query }) => {
const result = await sql`
SELECT
institution_id,
main_name_short,
main_name_german,
alias_name_short,
alias_name_german,
alias_type,
GREATEST(
similarity(main_name_short, ${query}),
similarity(main_name_german, ${query}),
similarity(alias_name_short, ${query}),
similarity(alias_name_german, ${query})
) AS match_score
FROM institutions.institution_search
WHERE
main_name_short % ${query}
OR main_name_german % ${query}
OR alias_name_short % ${query}
OR alias_name_german % ${query}
ORDER BY match_score DESC
LIMIT 5;
`.execute(kysely)
return result.rows
})
export const getDegreeMatches = pikkuSessionlessFunc<
{ query: string },
any
>(async ({ kysely }, { query }) => {
const result = await sql`
SELECT
degree_id,
main_name_short,
main_name_german,
alias_name_short,
alias_name_german,
alias_type,
GREATEST(
similarity(main_name_short, ${query}),
similarity(main_name_german, ${query}),
similarity(alias_name_short, ${query}),
similarity(alias_name_german, ${query})
) AS match_score
FROM institutions.degree_search
WHERE
main_name_short % ${query}
OR main_name_german % ${query}
OR alias_name_short % ${query}
OR alias_name_german % ${query}
ORDER BY match_score DESC
LIMIT 5;
`.execute(kysely)
return result.rows
})

View File

@@ -0,0 +1,17 @@
import { wireHTTP } from '#pikku/pikku-types.gen.js'
import { getInstitutionMatches, getDegreeMatches } from './institution.functions.js'
wireHTTP({
method: 'get',
route: '/institutions/institution',
func: getInstitutionMatches,
auth: false,
})
wireHTTP({
method: 'get',
route: '/institutions/degree',
func: getDegreeMatches,
auth: false,
})

View File

@@ -0,0 +1,358 @@
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import type { DB } from '@heygermany/sdk'
import {
CandidateResults,
DocumentStatuses,
type QualificationCard,
type GetCandidateResult,
type SupportedLocale,
} from '@heygermany/sdk'
import jsonLogic from "json-logic-js"
import { createCandidateTextEngine } from "../../engines/candidate-text-engine.js"
import { createCandidateCardEngine } from "../../engines/candidate-card-engine.js"
import { TranslationService } from '../../services/translation-service.js'
import { isBackOffice, isCandidate } from '../../permissions.js'
import { NotFoundError } from '@pikku/core/errors'
// Helper function to determine document status
const getDocumentStatus = (documents: Pick<DB.CandidateDocument, 'category' | 'documentStatus'>[], category: string): DB.DocumentStatus => {
const categoryDocs = documents.filter(d => d.category === category)
if (categoryDocs.length === 0) {
return DocumentStatuses.MISSING // No documents = Missing
}
// If any document is verified, consider it verified
if (categoryDocs.some(d => d.documentStatus === DocumentStatuses.VERIFIED)) {
return DocumentStatuses.VERIFIED
}
// If any document is invalid, consider it invalid
if (categoryDocs.some(d => d.documentStatus === DocumentStatuses.INVALID)) {
return DocumentStatuses.INVALID
}
// Documents exist but none are verified or invalid = unverified
return DocumentStatuses.UNVERIFIED
}
type OutputBlock = {
hero: string
result: string
explanation: string[]
recognition?: string[]
}
function getByPath<T = any>(obj: any, path: string): T | undefined {
return path.split(".").reduce((acc, k) => (acc ? acc[k] : undefined), obj)
}
function interpolate(str: string, vars: Record<string, string | number>) {
return str.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? ""))
}
function withInterpolation(block: OutputBlock, vars: Record<string, string | number>): OutputBlock {
return {
hero: interpolate(block.hero, vars),
result: interpolate(block.result, vars),
explanation: block.explanation.map((s) => interpolate(s, vars)),
recognition: block.recognition?.map((s) => interpolate(s, vars)),
}
}
function applyNotes(
translation: TranslationService,
candidate: GetCandidateResult,
locale: string = 'en'
): { afterExplanation: string[] } {
const CANDIDATE_TEXT_ENGINE = createCandidateTextEngine(translation, locale)
const notes = CANDIDATE_TEXT_ENGINE.notes
const afterExplanation: string[] = []
for (const n of notes) {
if (jsonLogic.apply(n.when as any, candidate)) {
if (n.attach?.where === "afterExplanation") {
// Use textKey with locale lookup if available, fallback to text
const noteText = (n as any).textKey
? translation.t((n as any).textKey, locale, (n as any).textKey)
: (n as any).text || '[Missing note text]'
afterExplanation.push(noteText)
}
}
}
return { afterExplanation }
}
function buildCandidateResults(translation: TranslationService, candidate: GetCandidateResult, locale: string): CandidateResults {
const CANDIDATE_TEXT_ENGINE = createCandidateTextEngine(translation, locale)
const CANDIDATE_CARD_ENGINE = createCandidateCardEngine(translation, locale)
// Determine render path first (needed for next steps)
let renderPath: string | null = null
for (const r of CANDIDATE_TEXT_ENGINE.rules) {
if (jsonLogic.apply(r.when as any, candidate)) {
renderPath = r.render
break
}
}
// EDUCATION
const eduRule = CANDIDATE_CARD_ENGINE.education.rules.find((r) =>
jsonLogic.apply(r.when as any, candidate)
)!
const education: QualificationCard = {
title: translation.t('education.title', locale),
description:
eduRule.status === "completed"
? CANDIDATE_CARD_ENGINE.education.getCompletedDescription()
: CANDIDATE_CARD_ENGINE.education.getRequiredDescription(),
status: eduRule.status as QualificationCard['status'],
requirements: [...CANDIDATE_CARD_ENGINE.education.getRequirements()],
steps:
eduRule.status === "required"
? [CANDIDATE_CARD_ENGINE.education.pickNextStep(candidate)].filter(Boolean) as string[]
: undefined,
}
// LANGUAGE
const language = CANDIDATE_CARD_ENGINE.language.build(candidate)
// LICENSE
const licRule = CANDIDATE_CARD_ENGINE.license.rules.find((r) =>
jsonLogic.apply(r.when as any, candidate)
)!
const license: QualificationCard = {
title: translation.t('license.title', locale),
description:
licRule.status === "completed"
? CANDIDATE_CARD_ENGINE.license.getDescriptionCompleted()
: CANDIDATE_CARD_ENGINE.license.getDescriptionRequired(),
status: licRule.status as QualificationCard['status'],
requirements: [...CANDIDATE_CARD_ENGINE.license.getRequirements()],
steps: licRule.status === "required" ? [...CANDIDATE_CARD_ENGINE.license.getSteps()] : undefined,
}
// ADDITIONAL (3 subcards)
const experience = CANDIDATE_CARD_ENGINE.additional.experience(candidate)
const copies = CANDIDATE_CARD_ENGINE.additional.copies()
const originals = CANDIDATE_CARD_ENGINE.additional.originals()
// TEXT CONTENT
// Fallback if nothing matched (be explicit)
let textContent: CandidateResults['textContent']
if (!renderPath) {
console.log('No matching render path for candidate:', candidate)
textContent = {
renderPath: null,
hero: "No eligible template for this candidate.",
result: "",
explanation: [],
recognition: undefined,
notes: { afterExplanation: [] as string[] },
}
} else {
// 2) Fetch output block by render path (e.g., "nurse.missingLicense")
const outputs = CANDIDATE_TEXT_ENGINE.getOutputs()
const template = getByPath<OutputBlock>(outputs, renderPath.toLowerCase())
if (!template) {
textContent = {
renderPath,
hero: "Template not found.",
result: "",
explanation: [],
recognition: undefined,
notes: { afterExplanation: [] as string[] },
}
} else {
// 3) Evaluate notes separately
const notesApplied = applyNotes(translation, candidate, locale)
// 4) Interpolate {{name}} (extend vars as needed)
const vars = { name: candidate.name ?? "" }
const interpolated = withInterpolation(template, vars)
textContent = {
renderPath,
hero: interpolated.hero,
result: interpolated.result,
explanation: interpolated.explanation,
recognition: interpolated.recognition,
notes: notesApplied,
}
}
}
// Get next steps with pathway-specific overrides and conditional german step
const nextSteps = CANDIDATE_TEXT_ENGINE.getNextSteps(renderPath, candidate)
return {
education,
license,
language,
experience,
copies,
originals,
joinTalentPool: candidate.joinTalentPool,
nextSteps,
textContent,
}
}
export const getCandidateResult = pikkuFunc<
Pick<DB.Candidate, 'candidateId'>,
GetCandidateResult
>({
expose: true,
permissions: { isBackOffice, isCandidate },
title: 'Get candidate qualification result',
description: 'Returns qualification summary data for a candidate',
errors: [NotFoundError],
func: async ({ kysely, logger }, { candidateId }) => {
const { durationRequirementsMet, ...candidate } = await kysely
.selectFrom('candidate')
.select([
'durationRequirementsMet',
'name',
'joinTalentPool',
'qualificationStatusGermany',
'qualificationStatusHomeCountry',
'euPassport',
'livingInGermany',
'licenseMandatory',
'languageSelfAssessmentLevel',
'languageCertificateProvided'
])
.where('candidateId', '=', candidateId)
.executeTakeFirstOrThrow(() => new NotFoundError('Candidate not found'))
// Get candidate-level education analysis.
const education = await kysely
.selectFrom('candidateEducation')
.select([
'nursingRelatedDegree',
'accreditedUniversity',
'accreditedStudyProgram',
'isNursingFocus',
'hasTheory',
'hasPractice'
])
.where('candidateId', '=', candidateId)
.executeTakeFirst()
// Language analysis is now candidate-level (no document linkage).
const languageDocs = await kysely
.selectFrom('candidateLanguage')
.select(['level'])
.where('candidateLanguage.candidateId', '=', candidateId)
.orderBy('level', 'desc')
.executeTakeFirst()
const hasPracticeHours = education?.hasPractice ?? null
const hasTheoryHours = education?.hasTheory ?? null
const isNursingFocus = education?.isNursingFocus ?? null
// Get all documents to determine status
const documents = await kysely
.selectFrom('candidateDocument')
.select(['category', 'documentStatus'])
.where('candidateId', '=', candidateId)
.execute()
const licenseStatus = getDocumentStatus(documents, 'license')
const cvStatus = getDocumentStatus(documents, 'work_experience')
const languageStatus = getDocumentStatus(documents, 'language')
const nursingRelatedDegree = education?.nursingRelatedDegree ?? null
const accreditedUniversity = education?.accreditedUniversity ?? null
const accreditedStudyProgram = education?.accreditedStudyProgram ?? null
const structuredTrainingOverall =
hasPracticeHours === null ||
hasTheoryHours === null ||
isNursingFocus === null ||
durationRequirementsMet === null
? null
: (hasPracticeHours && hasTheoryHours && isNursingFocus && durationRequirementsMet)
const languageLevel = candidate.languageSelfAssessmentLevel || languageDocs?.level
// Determine language assessment type
const languageAssessmentType = languageDocs ? 'certificate' : 'self_assessment'
// Check if all required fields are not null for validation
const isValid = [
candidate.qualificationStatusGermany,
candidate.qualificationStatusHomeCountry,
candidate.euPassport,
candidate.livingInGermany,
candidate.licenseMandatory,
languageLevel,
nursingRelatedDegree,
accreditedUniversity,
accreditedStudyProgram,
structuredTrainingOverall
].every(value => value !== null && value !== undefined)
if (!isValid) {
logger.info(`Candidate ${candidateId} data is incomplete for validation`, {
qualificationStatusGermany: candidate.qualificationStatusGermany,
qualificationStatusHomeCountry: candidate.qualificationStatusHomeCountry,
euPassport: candidate.euPassport,
livingInGermany: candidate.livingInGermany,
licenseMandatory: candidate.licenseMandatory,
languageLevel,
nursingRelatedDegree,
accreditedUniversity,
accreditedStudyProgram,
structuredTrainingOverall
})
}
return {
// Fields from candidate table
...candidate,
languageLevel,
languageAssessmentType,
// Aggregated education fields
accreditedUniversity,
accreditedStudyProgram,
nursingRelatedDegree,
// Transcript with sub-fields
structuredTraining: {
hasDuration: durationRequirementsMet,
isNursingFocus,
hasTheoryHours,
hasPracticeHours,
overallStatus: structuredTrainingOverall
},
// Document status
docs: {
cv: cvStatus,
language: languageStatus,
license: licenseStatus
},
// Validation
isValid
}
}
})
export const getCandidateResults = pikkuFunc<
{ candidateId: string, locale: SupportedLocale },
CandidateResults
>({
title: 'Get candidate qualification results',
description: 'Returns qualification cards for education, license, language, and additional requirements',
permissions: {
isBackOffice,
isCandidate
},
func: async ({ translation }, { candidateId, locale = 'en' }, { rpc }) => {
const candidate = await rpc.invoke('getCandidateResult', { candidateId })
const results = buildCandidateResults(translation, candidate, locale)
return results
}
})

View File

@@ -0,0 +1,393 @@
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 })
}
})
}
})

View File

@@ -0,0 +1,54 @@
import { wireHTTP } from '#pikku/pikku-types.gen.js'
import { addCandidateDocuments, deleteCandidateDocument, getCandidate, getCandidateById, startFlow, updateCandidate, updateCandidateDocument } from './candidate.functions.js'
import { getCandidateResults } from './candidate-results.functions.js'
wireHTTP({
method: 'post',
route: '/candidate',
func: startFlow,
auth: false,
})
wireHTTP({
method: 'get',
route: '/candidate',
func: getCandidate,
})
wireHTTP({
method: 'get',
route: '/candidate/:candidateId',
func: getCandidateById,
})
wireHTTP({
method: 'patch',
route: '/candidate/:candidateId',
func: updateCandidate,
})
wireHTTP({
method: 'post',
route: '/candidate/:candidateId/document',
func: addCandidateDocuments,
})
wireHTTP({
method: 'patch',
route: '/candidate/:candidateId/document/:documentId',
func: updateCandidateDocument,
})
wireHTTP({
method: 'delete',
route: '/candidate/:candidateId/document/:documentId',
func: deleteCandidateDocument,
})
wireHTTP({
method: 'get',
route: '/candidate/:candidateId/results',
func: getCandidateResults,
})

View File

@@ -0,0 +1,6 @@
import { pikkuVoidFunc } from '#pikku/pikku-types.gen.js'
// Disabled intentionally: lifecycle reminders are now handled by Lifecycle V2 orchestration.
export const checkIncompleteProcesses = pikkuVoidFunc(async ({ logger }) => {
logger.info('checkIncompleteProcesses is disabled; use lifecycle v2 processors instead')
})

View File

@@ -0,0 +1,11 @@
export interface CreateLeadInput {
firstName: string;
lastName: string;
email: string;
phone?: string;
facilityName: string;
facilityType: string;
nursesNeededRange?: string;
message?: string;
}
export declare const createLead: import("#pikku/pikku-types.gen.js").PikkuFunctionConfig<CreateLeadInput, void, "session" | "rpc", import("#pikku/pikku-types.gen.js").PikkuFunctionSessionless<CreateLeadInput, void, "session" | "rpc", import("#pikku/pikku-types.gen.js").Services> | import("#pikku/pikku-types.gen.js").PikkuFunction<CreateLeadInput, void, "session" | "rpc", import("#pikku/pikku-types.gen.js").Services>, undefined, undefined>;

View File

@@ -0,0 +1,63 @@
import { pikkuSessionlessFunc } from '#pikku/pikku-types.gen.js';
import { BadRequestError } from '@pikku/core/errors';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const FACILITY_TYPES = new Set([
'krankenhaus',
'altenpflege',
'ambulant',
'reha',
'sonstige',
]);
const NURSES_NEEDED_RANGES = new Set(['1-5', '6-15', '16-30', '30+']);
const normalizeOptionalField = (value) => {
const normalized = value?.trim();
return normalized ? normalized : null;
};
const requireField = (value, label) => {
const normalized = value.trim();
if (!normalized) {
throw new BadRequestError(`${label} is required`);
}
return normalized;
};
export const createLead = pikkuSessionlessFunc({
auth: false,
title: 'Create B2B lead',
description: 'Stores a pilot landing page lead in the database',
func: async ({ kysely }, data) => {
const firstName = requireField(data.firstName, 'firstName');
const lastName = requireField(data.lastName, 'lastName');
const email = requireField(data.email, 'email').toLowerCase();
const facilityName = requireField(data.facilityName, 'facilityName');
const facilityType = requireField(data.facilityType, 'facilityType');
const phone = normalizeOptionalField(data.phone);
const nursesNeededRange = normalizeOptionalField(data.nursesNeededRange);
const message = normalizeOptionalField(data.message);
if (!EMAIL_REGEX.test(email)) {
throw new BadRequestError('email is invalid');
}
if (!FACILITY_TYPES.has(facilityType)) {
throw new BadRequestError('facilityType is invalid');
}
if (nursesNeededRange &&
!NURSES_NEEDED_RANGES.has(nursesNeededRange)) {
throw new BadRequestError('nursesNeededRange is invalid');
}
await kysely
.insertInto('leads')
.values({
leadId: crypto.randomUUID(),
firstName,
lastName,
email,
phone,
facilityName,
facilityType,
nursesNeededRange,
message,
source: 'pilot',
})
.executeTakeFirstOrThrow();
},
});
//# sourceMappingURL=leads.functions.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"leads.functions.js","sourceRoot":"","sources":["leads.functions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA;AAChE,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAA;AAEpD,MAAM,WAAW,GAAG,4BAA4B,CAAA;AAChD,MAAM,cAAc,GAAG,IAAI,GAAG,CAAC;IAC7B,aAAa;IACb,aAAa;IACb,UAAU;IACV,MAAM;IACN,UAAU;CACX,CAAC,CAAA;AACF,MAAM,oBAAoB,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,CAAA;AAarE,MAAM,sBAAsB,GAAG,CAAC,KAAc,EAAE,EAAE;IAChD,MAAM,UAAU,GAAG,KAAK,EAAE,IAAI,EAAE,CAAA;IAChC,OAAO,UAAU,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAA;AACvC,CAAC,CAAA;AAED,MAAM,YAAY,GAAG,CAAC,KAAa,EAAE,KAAa,EAAE,EAAE;IACpD,MAAM,UAAU,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;IAE/B,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,eAAe,CAAC,GAAG,KAAK,cAAc,CAAC,CAAA;IACnD,CAAC;IAED,OAAO,UAAU,CAAA;AACnB,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,UAAU,GAAG,oBAAoB,CAG5C;IACA,IAAI,EAAE,KAAK;IACX,KAAK,EAAE,iBAAiB;IACxB,WAAW,EAAE,kDAAkD;IAC/D,IAAI,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE;QAC/B,MAAM,SAAS,GAAG,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,WAAW,CAAC,CAAA;QAC3D,MAAM,QAAQ,GAAG,YAAY,CAAC,IAAI,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAA;QACxD,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,WAAW,EAAE,CAAA;QAC7D,MAAM,YAAY,GAAG,YAAY,CAAC,IAAI,CAAC,YAAY,EAAE,cAAc,CAAC,CAAA;QACpE,MAAM,YAAY,GAAG,YAAY,CAAC,IAAI,CAAC,YAAY,EAAE,cAAc,CAAC,CAAA;QACpE,MAAM,KAAK,GAAG,sBAAsB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QAChD,MAAM,iBAAiB,GAAG,sBAAsB,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAA;QACxE,MAAM,OAAO,GAAG,sBAAsB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;QAEpD,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;YAC7B,MAAM,IAAI,eAAe,CAAC,kBAAkB,CAAC,CAAA;QAC/C,CAAC;QAED,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,CAAC;YACtC,MAAM,IAAI,eAAe,CAAC,yBAAyB,CAAC,CAAA;QACtD,CAAC;QAED,IACE,iBAAiB;YACjB,CAAC,oBAAoB,CAAC,GAAG,CAAC,iBAAiB,CAAC,EAC5C,CAAC;YACD,MAAM,IAAI,eAAe,CAAC,8BAA8B,CAAC,CAAA;QAC3D,CAAC;QAED,MAAM,MAAM;aACT,UAAU,CAAC,OAAO,CAAC;aACnB,MAAM,CAAC;YACN,MAAM,EAAE,MAAM,CAAC,UAAU,EAAE;YAC3B,SAAS;YACT,QAAQ;YACR,KAAK;YACL,KAAK;YACL,YAAY;YACZ,YAAY;YACZ,iBAAiB;YACjB,OAAO;YACP,MAAM,EAAE,OAAO;SAChB,CAAC;aACD,uBAAuB,EAAE,CAAA;IAC9B,CAAC;CACF,CAAC,CAAA"}

View File

@@ -0,0 +1,88 @@
import { pikkuSessionlessFunc } from '#pikku/pikku-types.gen.js'
import { BadRequestError } from '@pikku/core/errors'
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const FACILITY_TYPES = new Set([
'krankenhaus',
'altenpflege',
'ambulant',
'reha',
'sonstige',
])
const NURSES_NEEDED_RANGES = new Set(['1-5', '6-15', '16-30', '30+'])
export interface CreateLeadInput {
firstName: string
lastName: string
email: string
phone?: string
facilityName: string
facilityType: string
nursesNeededRange?: string
message?: string
}
const normalizeOptionalField = (value?: string) => {
const normalized = value?.trim()
return normalized ? normalized : null
}
const requireField = (value: string, label: string) => {
const normalized = value.trim()
if (!normalized) {
throw new BadRequestError(`${label} is required`)
}
return normalized
}
export const createLead = pikkuSessionlessFunc<
CreateLeadInput,
void
>({
auth: false,
title: 'Create B2B lead',
description: 'Stores a pilot landing page lead in the database',
func: async ({ kysely }, data) => {
const firstName = requireField(data.firstName, 'firstName')
const lastName = requireField(data.lastName, 'lastName')
const email = requireField(data.email, 'email').toLowerCase()
const facilityName = requireField(data.facilityName, 'facilityName')
const facilityType = requireField(data.facilityType, 'facilityType')
const phone = normalizeOptionalField(data.phone)
const nursesNeededRange = normalizeOptionalField(data.nursesNeededRange)
const message = normalizeOptionalField(data.message)
if (!EMAIL_REGEX.test(email)) {
throw new BadRequestError('email is invalid')
}
if (!FACILITY_TYPES.has(facilityType)) {
throw new BadRequestError('facilityType is invalid')
}
if (
nursesNeededRange &&
!NURSES_NEEDED_RANGES.has(nursesNeededRange)
) {
throw new BadRequestError('nursesNeededRange is invalid')
}
await kysely
.insertInto('leads')
.values({
leadId: crypto.randomUUID(),
firstName,
lastName,
email,
phone,
facilityName,
facilityType,
nursesNeededRange,
message,
source: 'pilot',
})
.executeTakeFirstOrThrow()
},
})

View File

@@ -0,0 +1,9 @@
import { wireHTTP } from '#pikku/pikku-types.gen.js'
import { createLead } from './leads.functions.js'
wireHTTP({
method: 'post',
route: '/leads',
func: createLead,
auth: false,
})

View File

@@ -0,0 +1 @@
export * as config from './config.js'

View File

@@ -0,0 +1,2 @@
export const LOCAL_SQLITE_DB = '.pikku-runtime/dev.db';
//# sourceMappingURL=local-db.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"local-db.js","sourceRoot":"","sources":["local-db.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG,uBAAuB,CAAA"}

View File

@@ -0,0 +1 @@
export const LOCAL_SQLITE_DB = '.pikku-runtime/dev.db'

View File

@@ -0,0 +1,45 @@
import { addHTTPMiddleware } from "#pikku/pikku-types.gen.js"
import { authBearer, cors } from '@pikku/core/middleware'
import { betterAuthStatelessSession } from '@pikku/better-auth'
addHTTPMiddleware('*', [
// Stateless: reads the signed session cookie cache (needs session.cookieCache
// in auth.wiring) instead of services.auth(), which is tree-shaken off the RPC
// bundle on the deployed CF Worker (betterAuthSession 500s there with
// "services.auth is not a function").
betterAuthStatelessSession({
mapSession: ({ user }) => ({
userId: user.id,
name: user.name,
role: user.role,
type: user.type,
}),
}),
// External consoles (sandbox/woven-build view) can't carry the session cookie,
// so they authenticate with `Authorization: Bearer <PIKKU_CONSOLE_TOKEN>`. The
// CLI normally emits this alongside its session middleware, but our own global
// session middleware makes it step aside (pikku#754) — so add the console-token
// bridge here or console:* RPCs 403 in the sandbox.
authBearer({
token: {
secretId: 'PIKKU_CONSOLE_TOKEN',
userSession: { userId: 'pikku-console-token' },
},
}),
])
const frontendOrigins = [
'http://localhost:3001',
'http://127.0.0.1:3001',
]
if (process.env.DOMAIN) {
frontendOrigins.push(`https://${process.env.DOMAIN}`)
}
addHTTPMiddleware('*', [
cors({
origin: frontendOrigins,
credentials: true,
}),
])

View File

@@ -0,0 +1 @@
export declare const normalizeStringArray: <T extends string = string>(value: T[] | string | null | undefined) => T[] | null;

View File

@@ -0,0 +1,19 @@
export const normalizeStringArray = (value) => {
if (value == null)
return null;
if (Array.isArray(value))
return value;
const trimmed = value.trim();
if (!trimmed)
return [];
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.filter((item) => typeof item === 'string');
}
}
catch {
}
return [trimmed];
};
//# sourceMappingURL=normalizers.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"normalizers.js","sourceRoot":"","sources":["normalizers.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAClC,KAAsC,EAC1B,EAAE;IACd,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,IAAI,CAAA;IAC9B,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IAEtC,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;IAC5B,IAAI,CAAC,OAAO;QAAE,OAAO,EAAE,CAAA;IAEvB,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;QAClC,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YAC1B,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAa,EAAE,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAA;QACrE,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;IAET,CAAC;IAED,OAAO,CAAC,OAAY,CAAC,CAAA;AACvB,CAAC,CAAA"}

View File

@@ -0,0 +1,20 @@
export const normalizeStringArray = <T extends string = string>(
value: T[] | string | null | undefined
): T[] | null => {
if (value == null) return null
if (Array.isArray(value)) return value
const trimmed = value.trim()
if (!trimmed) return []
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
return parsed.filter((item): item is T => typeof item === 'string')
}
} catch {
// fall through
}
return [trimmed as T]
}

View File

@@ -0,0 +1,18 @@
import { pikkuPermission } from "#pikku/pikku-types.gen.js";
export const isBackOffice = pikkuPermission(async (_services, _data, { session }) => {
return session?.type === 'backoffice'
})
export const isCompany = pikkuPermission(async (_services, _data, { session }) => {
return session?.type === 'company'
})
export const isCandidate = pikkuPermission<{ candidateId?: string } | void>(async (_services, data, { session }) => {
if (session?.type !== 'candidate') {
return false
}
if (data?.candidateId && data.candidateId !== session.userId) {
return false
}
return true
})

View File

@@ -0,0 +1,11 @@
import { wireSecret } from '@pikku/core/secret';
import { z } from 'zod';
export const BetterAuthSecretSchema = z.string();
wireSecret({
name: 'betterAuthSecret',
displayName: 'Better Auth Secret',
description: 'Signing secret for better-auth sessions',
secretId: 'BETTER_AUTH_SECRET',
schema: BetterAuthSecretSchema,
});
//# sourceMappingURL=auth-secrets.gen.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"auth-secrets.gen.js","sourceRoot":"","sources":["auth-secrets.gen.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAC/C,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,CAAC,MAAM,EAAE,CAAA;AAEhD,UAAU,CAAC;IACT,IAAI,EAAE,kBAAkB;IACxB,WAAW,EAAE,oBAAoB;IACjC,WAAW,EAAE,yCAAyC;IACtD,QAAQ,EAAE,oBAAoB;IAC9B,MAAM,EAAE,sBAAsB;CAC/B,CAAC,CAAA"}

View File

@@ -0,0 +1,74 @@
import type {
SecretService,
} from '@pikku/core'
import type { KyselyDB } from './database-types.js'
import {
JsonConsoleLogger,
LocalSecretService,
LocalVariablesService,
} from '@pikku/core/services'
import { CFWorkerSchemaService } from '@pikku/schema-cfworker'
import './middleware.js'
import { ProjectEmailService } from './services/email-service.js'
import { MailgunEmailService } from './services/mailgun/mailgun.js'
import { RecognitionDossierService } from './services/recognition-dossier-service.js'
import { TranslationService } from './services/translation-service.js'
import {
pikkuServices,
pikkuWireServices,
} from '#pikku/function/pikku-function-types.gen.js'
export const createSingletonServices = pikkuServices(async (config, existingServices) => {
const variables = existingServices?.variables ?? new LocalVariablesService()
const logger = existingServices?.logger ?? new JsonConsoleLogger()
const secrets: SecretService = existingServices?.secrets ?? new LocalSecretService(variables)
if (config.logLevel) {
logger.setLevel(config.logLevel)
}
const schema = existingServices?.schema ?? new CFWorkerSchemaService(logger)
if (!existingServices?.kysely) {
throw new Error('kysely service was not injected by the runtime (pikku dev / fabric)')
}
const kysely = existingServices.kysely as typeof existingServices.kysely & KyselyDB
if (!existingServices?.content) {
throw new Error('content service was not injected by the runtime (pikku dev / fabric)')
}
const content = existingServices.content
const emailDelegate =
existingServices?.emailService ??
new MailgunEmailService(
variables.get('NODE_ENV') === 'production' ? await secrets.getSecret(config.secrets.mailgunApiKey) : undefined,
config.domain
)
const emailService = new ProjectEmailService(emailDelegate)
const recognitionDossierService =
existingServices?.recognitionDossierService ??
new RecognitionDossierService(kysely, content, logger)
const translation = existingServices?.translation ?? new TranslationService(kysely)
if (!existingServices?.translation) {
await translation.init()
}
return {
...(existingServices ?? {}),
config,
variables,
secrets,
schema,
logger,
kysely,
content,
emailService,
recognitionDossierService,
translation,
}
})
export const createWireServices = pikkuWireServices(async () => ({
}))

View File

@@ -0,0 +1,586 @@
export const backendTranslationSnapshot = [
{
"locale": "ar",
"namespace": "cards",
"content": {}
},
{
"locale": "ar",
"namespace": "misc",
"content": {}
},
{
"locale": "ar",
"namespace": "outputs",
"content": {}
},
{
"locale": "bs",
"namespace": "cards",
"content": {}
},
{
"locale": "bs",
"namespace": "misc",
"content": {}
},
{
"locale": "bs",
"namespace": "outputs",
"content": {}
},
{
"locale": "de",
"namespace": "cards",
"content": {
"additional": {
"copies": {
"description": "Stellen Sie beglaubigte oder zertifizierte Kopien der erforderlichen Dokumente zur Verfügung.",
"requirements": [
"Passkopie",
"Kopien der Bildungsdokumente",
"Kopie des polizeilichen Führungszeugnisses",
"Kopie der Unbedenklichkeitsbescheinigung"
],
"steps": [
"Dokumentenkopien vorbereiten",
"Bei Bedarf von vereidigtem Übersetzer übersetzen lassen"
],
"title": "Kopien"
},
"experience": {
"description": "Laden Sie Ihren Lebenslauf auf unsere Plattform hoch, um sich mit Arbeitgebern zu verbinden.",
"requirements": [
"Lebenslauf (CV)"
],
"steps": [
"Erstellen Sie Ihren Lebenslauf",
"Laden Sie ihn in Ihr Profil hoch"
],
"title": "Berufserfahrung"
},
"originals": {
"description": "Bereiten Sie erforderliche Originale für die Einreichung bei der Behörde vor.",
"requirements": [
"Ausgefülltes Antragsformular",
"Lebenslauf (unterschrieben und datiert)",
"Ärztliches Attest",
"Führungszeugnis"
],
"steps": [
"Originale vorbereiten",
"Bei der Behörde einreichen"
],
"title": "Originale"
}
},
"education": {
"completeddescription": "Abgeschlossenes und anerkanntes Pflegeausbildungsprogramm.",
"requireddescription": "Pflegeausbildungsprogramm nicht abgeschlossen oder anerkannt.",
"requirements": [
"Pflegeabschluss",
"Anerkanntes Studienprogramm",
"Strukturierte Ausbildung"
],
"steps": {
"missaccredited": "Ihre Universität ist möglicherweise nicht akkreditiert, aber ein Berater oder LaGeSo kann Ihnen helfen herauszufinden, ob Sie sich dennoch für eine Berufsanerkennung qualifizieren.",
"missdegree": "Ihr Abschluss entspricht nicht dem Pflegeberuf in Deutschland, aber Sie könnten sich für eine andere Rolle qualifizieren. Ein Berater kann Ihnen helfen, den Beruf zu finden, der am besten zu Ihrem Hintergrund passt.",
"missstructured": "Ihre Ausbildungsstruktur entspricht nicht den deutschen Anforderungen, aber ein Berater oder LaGeSo kann Ihnen helfen, Ihre Möglichkeiten für den Beginn einer Pflegelaufbahn in Deutschland zu erkunden."
}
},
"language": {
"description": "Nachweis der erforderlichen deutschen Sprachkenntnisse.",
"levelnames": {
"none": "KEINES"
},
"reminder": "Erinnerung: Sie müssen Ihr Deutsch-Niveau mit einem Zertifikat nachweisen.",
"requirements": {
"certificate": "Sprachzertifikat (Goethe-Institut, telc, TestDaF oder ECL)",
"germanlevel": "Deutsch Niveau {level}"
},
"steps": {
"getcertificate": "Erhalten Sie ein Sprachzertifikat vom Goethe-Institut, telc, TestDaF oder ECL",
"improvelevel": "Verbessern Sie Ihr Deutsch-Niveau von {currentLevel} auf {requiredLevel}"
},
"title": "Deutsche Sprachkenntnisse"
},
"license": {
"descriptioncompleted": "Gültige Pflegelizenz oder -registrierung aus Ihrem Heimatland.",
"descriptionrequired": "Fehlende Pflegelizenz oder -registrierung aus Ihrem Heimatland.",
"requirements": [
"Pflegelizenz aus Ihrem Heimatland erforderlich für Berufsanerkennung"
],
"steps": [
"Beschaffen Sie Ihre Lizenz aus Ihrem Heimatland"
]
}
}
},
{
"locale": "de",
"namespace": "misc",
"content": {
"notes": {
"visanote": "Bitte beachten Sie, dass Sie ein B1-Deutschzertifikat benötigen, um ein Visum für Deutschland zu erhalten."
}
}
},
{
"locale": "de",
"namespace": "outputs",
"content": {
"assistant": {
"allmet": {
"explanation": [
"Basierend auf Ihren Dokumenten erfüllen Sie die Voraussetzungen, um als Pflegeassistent (Pflegefachassistenz) in Berlin zu arbeiten.",
"Das bedeutet, Sie sind auf dem richtigen Weg mit der Berufsanerkennung, die für internationale Pflegekräfte obligatorisch ist, können Sie Ihre berufliche Laufbahn in Deutschland beginnen. Deutschland braucht qualifizierte Pflegekräfte wie Sie!"
],
"hero": "Ein erster Schritt zu Ihrer Pflegelaufbahn, {{name}}",
"nextsteps": [
"Reichen Sie Ihren Antrag ein → Beantragen Sie die Erlaubnis zur Führung der Berufsbezeichnung \\\"Pflegeassistent (Pflegefachassistenz)\\\" bei LaGeSo (stellen Sie vollständige Dokumente sicher).",
"Verbessern Sie Ihr Deutsch → <strong>B1</strong> Ziel."
],
"recognition": [
"🔍 Wahrscheinliches Ergebnis der Berufsanerkennung: Teilweise Berufsanerkennung",
"Ihre Qualifikation entspricht <i>teilweise</i> den deutschen Pflegestandards das ist eine starke Grundlage!",
"Um eine vollständige Anerkennung zu erreichen und als Pflegekraft arbeiten zu können, müssen Sie möglicherweise eine <strong>Ausgleichsmaßnahme</strong> absolvieren und <strong>B1</strong> in Deutsch erreichen."
],
"result": "Sie qualifizieren sich als Pflegeassistent in Berlin"
},
"missinglicense": {
"explanation": [
"Basierend auf Ihren Dokumenten erfüllen Sie bereits einige der Voraussetzungen, um als Pflegeassistent (Pflegefachassistenz) in Berlin zu arbeiten. Um voranzukommen, müssen Sie noch einige wichtige Dokumente für die Anerkennung Ihrer Qualifikation in Deutschland bereitstellen am wichtigsten ist Ihre Pflegelizenz oder Pflegeregistrierung aus Ihrem Heimatland.",
"Das bedeutet, Sie sind bereits auf dem richtigen Weg! Bitte stellen Sie sicher, dass Sie alle erforderlichen Dokumente für die Berufsanerkennung bereit haben, sobald Sie den Prozess starten. Es gibt viele kostenlose Beratungsdienste, die Sie dabei unterstützen."
],
"hero": "Ein erster Schritt zu Ihrer Pflegelaufbahn, {{name}}",
"nextsteps": [
"Reichen Sie Ihren Antrag ein → LaGeSo.",
"Verbessern Sie Ihr Deutsch → <strong>B1</strong> Ziel."
],
"recognition": [
"🔍 Wahrscheinliches Ergebnis der Berufsanerkennung: Teilweise Berufsanerkennung",
"Um eine vollständige Anerkennung zu erreichen und als Pflegekraft arbeiten zu können, benötigen Sie möglicherweise eine Ausgleichsmaßnahme und B1 Deutsch.",
"Bitte beachten Sie: Ihre berufliche Lizenz wurde noch nicht hochgeladen. Es ist ein erforderliches Dokument für die Anerkennung."
],
"result": "Sie könnten sich als Pflegeassistent in Berlin qualifizieren"
},
"missinglicenseandaccreditation": {
"explanation": [
"Basierend auf Ihren Dokumenten erfüllen Sie bereits einige der Voraussetzungen, um als Pflegeassistent (Pflegefachassistenz) in Berlin zu arbeiten. Um voranzukommen, müssen Sie noch einige wichtige Dokumente bereitstellen am wichtigsten ist Ihre Pflegelizenz oder Pflegeregistrierung aus Ihrem Heimatland.",
"Außerdem konnten wir nicht verifizieren, dass Ihre Universität in Ihrem Heimatland <strong>akkreditiert</strong> ist. Dies ist für die Berufsanerkennung obligatorisch.",
"Das bedeutet, Sie sind bereits auf dem richtigen Weg! Bitte sprechen Sie mit einem Berater über den Akkreditierungsstatus Ihrer Universität und sammeln Sie alle erforderlichen Dokumente, bevor Sie den Anerkennungsprozess beginnen."
],
"hero": "Ein erster Schritt zu Ihrer Pflegelaufbahn, {{name}}",
"nextsteps": [
"Reichen Sie Ihren Antrag ein → LaGeSo.",
"Verbessern Sie Ihr Deutsch → <strong>B1</strong> Ziel."
],
"recognition": [
"🔍 Wahrscheinliches Ergebnis der Berufsanerkennung: Teilweise Berufsanerkennung",
"B1 Deutsch wahrscheinlich erforderlich.",
"Bitte beachten Sie: Ihre berufliche Lizenz wurde noch nicht hochgeladen. Sie müssen auch nachweisen, dass Ihr Studienprogramm akkreditiert ist ohne dies ist eine Berufsanerkennung unwahrscheinlich."
],
"result": "Sie könnten sich als Pflegeassistent in Berlin qualifizieren"
}
},
"helper": {
"default": {
"explanation": [
"Ihre Dokumente zeigen, dass Sie noch nicht alle Voraussetzungen erfüllen, um als Pflegefachkraft in Deutschland zu arbeiten. Aber Sie qualifizieren sich, als Pflegehelfer zu arbeiten eine Rolle, die Ihnen die Möglichkeit bietet, Erfahrungen im deutschen Gesundheitssystem zu sammeln. Da dies kein reglementierter Beruf ist, benötigen Sie keine Berufsanerkennung, um mit der Arbeit zu beginnen.",
"Wenn Ihr Ziel ist, eine qualifizierte Pflegekraft in Deutschland zu werden, können Sie den nächsten Schritt machen, indem Sie eine 3-jährige Pflegeausbildung (Pflegeausbildung) beginnen."
],
"hero": "Ein erster Schritt zu Ihrer Pflegelaufbahn, {{name}}",
"nextsteps": [
"Hilfe holen → Kostenlose Unterstützungsdienste für international ausgebildete Fachkräfte.",
"Mit Arbeitgebern vernetzen → Frühes Engagement erhöht den Erfolg; wir können Ihnen beim Vernetzen helfen.",
"Verbessern Sie Ihr Deutsch → Empfohlen mindestens B1; Finanzierungsmöglichkeiten verfügbar."
],
"result": "Sie können Ihre Reise in Deutschland als <em>Pflegehelfer</em> beginnen"
}
},
"nurse": {
"allmet": {
"explanation": [
"Basierend auf Ihren Dokumenten erfüllen Sie die Voraussetzungen, um als <strong>Pflegefachkraft</strong> in Berlin zu arbeiten.",
"Das bedeutet, Sie sind auf dem richtigen Weg mit der Berufsanerkennung, die für internationale Pflegekräfte obligatorisch ist, können Sie Ihre berufliche Laufbahn in Deutschland beginnen. <strong>Deutschland braucht qualifizierte Pflegekräfte wie Sie!</strong>"
],
"hero": "Großartige Neuigkeiten {{name}}",
"nextsteps": [
"Verbessern Sie Ihr Deutsch → Sie benötigen <strong>B2</strong>, um als Pflegekraft in Berlin zu arbeiten. Finanzierungsmöglichkeiten sind verfügbar."
],
"recognition": [
"🔍 Wahrscheinliches Ergebnis der Berufsanerkennung: Teilweise Berufsanerkennung",
"Ihre Qualifikation entspricht <i>teilweise</i> den deutschen Pflegestandards das ist eine starke Grundlage!",
"Um eine vollständige Anerkennung zu erreichen und als Pflegekraft arbeiten zu können, müssen Sie möglicherweise eine <strong>Ausgleichsmaßnahme</strong> absolvieren und <strong>B2</strong> in Deutsch erreichen."
],
"result": "Sie qualifizieren sich als Pflegekraft in Berlin"
},
"missinglicense": {
"explanation": [
"Basierend auf Ihren Dokumenten erfüllen Sie bereits einige der Voraussetzungen, um als <strong>Pflegefachkraft</strong> in Berlin zu arbeiten. Um voranzukommen, müssen Sie noch einige wichtige Dokumente für die Anerkennung Ihrer Qualifikation in Deutschland bereitstellen am wichtigsten ist Ihre Pflege-<strong>Lizenz</strong> oder Pflegeregistrierung aus Ihrem Heimatland.",
"Das bedeutet, Sie sind bereits auf dem richtigen Weg! Bitte stellen Sie sicher, dass Sie alle erforderlichen Dokumente für die Berufsanerkennung bereit haben, sobald Sie den Prozess starten. Es gibt viele kostenlose Beratungsdienste, die Sie dabei unterstützen."
],
"hero": "Gute Neuigkeiten {{name}}",
"nextsteps": [
"Verbessern Sie Ihr Deutsch → <strong>B2</strong> Ziel."
],
"recognition": [
"🔍 Wahrscheinliches Ergebnis der Berufsanerkennung: Teilweise Berufsanerkennung",
"Ihre Qualifikation entspricht <i>teilweise</i> den deutschen Pflegestandards das ist eine starke Grundlage!",
"Um eine vollständige Anerkennung zu erreichen und als Pflegekraft arbeiten zu können, müssen Sie möglicherweise eine <strong>Ausgleichsmaßnahme</strong> absolvieren und <strong>B2</strong> in Deutsch erreichen.",
"Bitte beachten Sie: Ihre berufliche <strong>Lizenz</strong> wurde noch nicht hochgeladen. Es ist ein erforderliches Dokument für die Anerkennung, stellen Sie also sicher, dass Sie es zur Verfügung haben, wenn Sie Ihren Antrag stellen."
],
"result": "Sie könnten sich als Pflegekraft in Berlin qualifizieren"
},
"missinglicenseandaccreditation": {
"explanation": [
"Basierend auf Ihren Dokumenten erfüllen Sie bereits einige der Voraussetzungen, um als <strong>Pflegefachkraft</strong> in Berlin zu arbeiten. Um voranzukommen, müssen Sie noch einige wichtige Dokumente für die Anerkennung Ihrer Qualifikation in Deutschland bereitstellen am wichtigsten ist Ihre Pflege-<strong>Lizenz</strong> oder Pflegeregistrierung aus Ihrem Heimatland.",
"Außerdem konnten wir nicht verifizieren, dass Ihre Universität in Ihrem Heimatland <strong>akkreditiert</strong> ist. Dies ist für die Berufsanerkennung obligatorisch.",
"Das bedeutet, Sie sind bereits auf dem richtigen Weg! Bitte sprechen Sie unbedingt mit einem Berater über den Akkreditierungsstatus Ihrer Universität und haben Sie alle erforderlichen Dokumente zusammen, bevor Sie den Anerkennungsprozess beginnen. Es gibt viele kostenlose Beratungsdienste, die Sie dabei unterstützen."
],
"hero": "Gute Neuigkeiten {{name}}",
"nextsteps": [
"Verbessern Sie Ihr Deutsch → <strong>B2</strong> Ziel."
],
"recognition": [
"🔍 Wahrscheinliches Ergebnis der Berufsanerkennung: Teilweise Berufsanerkennung",
"Ihre Qualifikation entspricht <i>teilweise</i> den deutschen Pflegestandards das ist eine starke Grundlage!",
"Um eine vollständige Anerkennung zu erreichen und als Pflegekraft arbeiten zu können, müssen Sie möglicherweise eine <strong>Ausgleichsmaßnahme</strong> absolvieren und <strong>B2</strong> in Deutsch erreichen.",
"Bitte beachten Sie: Ihre berufliche Lizenz wurde noch nicht hochgeladen… Sie müssen auch nachweisen, dass Ihr Studienprogramm in Ihrem Heimatland akkreditiert ist ohne dies ist eine Berufsanerkennung unwahrscheinlich."
],
"result": "Sie könnten sich als Pflegekraft in Berlin qualifizieren"
}
}
}
},
{
"locale": "en",
"namespace": "cards",
"content": {
"additional": {
"copies": {
"description": "Provide notarized or certified copies of required documents.",
"requirements": [
"Passport copy",
"Education documents copies",
"Police clearance certificate copy",
"Certificate of good standing copy"
],
"steps": [
"Prepare document copies",
"Translate by sworn translator if required"
],
"title": "Copies"
},
"experience": {
"description": "Upload your CV to our platform to connect with employers.",
"requirements": [
"Curriculum Vitae (CV)"
],
"steps": [
"Create your CV",
"Upload it to your profile"
],
"title": "Work Experience"
},
"originals": {
"description": "Prepare required originals for submission to the authority.",
"requirements": [
"Completed application form",
"CV (signed and dated)",
"Medical fitness certificate",
"Certificate of good conduct"
],
"steps": [
"Prepare originals",
"Submit to the authority"
],
"title": "Originals"
}
},
"education": {
"completeddescription": "Completed and approved nursing education program.",
"requireddescription": "Nursing education program not completed or approved.",
"requirements": [
"Nursing degree",
"Accredited study program",
"Structured training"
],
"steps": {
"missaccredited": "Your university may not be accredited, but an advisor or LaGeSo can help you find out whether you still qualify for job recognition.",
"missdegree": "Your degree doesn't match the nursing profession in Germany, but you could qualify for another role. An advisor can help you find the profession that best fits your background.",
"missstructured": "Your education structure does not match German requirements, but an advisor or LaGeSo can help you explore your options for starting a nursing career in Germany."
}
},
"language": {
"description": "Demonstrate required German language skills.",
"levelnames": {
"none": "NONE"
},
"reminder": "Reminder: you will need to prove your German level with a Certificate.",
"requirements": {
"certificate": "Language certificate (Goethe-Institut, telc, TestDaF, or ECL)",
"germanlevel": "German level {level}"
},
"steps": {
"getcertificate": "Get a language certificate from Goethe-Institut, telc, TestDaF, or ECL",
"improvelevel": "Improve your German level from {currentLevel} to {requiredLevel}"
},
"title": "German Proficiency"
},
"license": {
"descriptioncompleted": "Valid nursing license or registration from your home country.",
"descriptionrequired": "Missing nursing license or registration from your home country.",
"requirements": [
"Nursing license from your home country required for job recognition"
],
"steps": [
"Obtain your license from your home country"
]
}
}
},
{
"locale": "en",
"namespace": "misc",
"content": {
"notes": {
"visanote": "Please note that you need a B1 German language certificate in order to obtain a visa for Germany."
}
}
},
{
"locale": "en",
"namespace": "outputs",
"content": {
"assistant": {
"allmet": {
"explanation": [
"Based on your documents, you meet the requirements to work as a Nursing Assistant (Pflegefachassistenz) in Berlin.",
"This means you're on the right track with job recognition, which is mandatory for international nurses, you can start your professional journey in Germany. Germany needs skilled nurses like you!"
],
"hero": "A First Step Towards Your Nursing Career, {{name}}",
"nextsteps": [
"Submit your application → Apply for permission to use the title \"Assistant Nurse (Pflegefachassistenz)\" at LaGeSo (ensure complete docs).",
"Improve your German → <strong>B1</strong> target."
],
"recognition": [
"🔍 Likely Outcome of Job Recognition: Partial Job Recognition",
"Your qualification aligns <i>partially</i> with German nursing standards — that's a strong foundation!",
"To achieve full recognition and be able to work as a nurse you may need to complete a <strong>compensatory measure</strong> and reach <strong>B1</strong> in German."
],
"result": "You Qualify as a Nursing Assistant in Berlin"
},
"missinglicense": {
"explanation": [
"Based on your documents, you already fulfill some of the requirements to work as a Nursing Assistant (Pflegefachassistenz) in Berlin. To move forward, you still need to provide some important documents for your qualification to be recognized in Germany— most importantly, your Nursing License or Nursing Registration from your home country.",
"This means you're already on the right track! Please make sure you have all the required documents for job recognition ready once you start the process. There are many free advisory services available to support you along the way."
],
"hero": "A First Step Towards Your Nursing Career, {{name}}",
"nextsteps": [
"Submit your application → LaGeSo.",
"Improve your German → <strong>B1</strong> target."
],
"recognition": [
"🔍 Likely Outcome of Job Recognition: Partial Job Recognition",
"To achieve full recognition and be able to work as a nurse you may need a compensatory measure and B1 German.",
"Please note: Your professional license wasn't uploaded yet. It's a required document for recognition."
],
"result": "You May Qualify as a Nursing Assistant in Berlin"
},
"missinglicenseandaccreditation": {
"explanation": [
"Based on your documents, you already fulfill some of the requirements to work as a Nursing Assistant (Pflegefachassistenz) in Berlin. To move forward, you still need to provide some important documents — most importantly, your Nursing License or Nursing Registration from your home country.",
"Also we couldn't verify that your university is <strong>accredited</strong> in your home country. This is mandatory for job recognition.",
"This means you're already on the right track! Please talk with an advisor about your university accreditation status and gather all required documents before beginning the recognition process."
],
"hero": "A First Step Towards Your Nursing Career, {{name}}",
"nextsteps": [
"Submit your application → LaGeSo.",
"Improve your German → <strong>B1</strong> target."
],
"recognition": [
"🔍 Likely Outcome of Job Recognition: Partial Job Recognition",
"B1 German likely required.",
"Please note: Your professional license wasn't uploaded yet. You also need to provide proof that your study program is accredited — without this, job recognition is unlikely."
],
"result": "You May Qualify as a Nursing Assistant in Berlin"
}
},
"helper": {
"default": {
"explanation": [
"Your documents show that you don't yet meet all the requirements to work as a Nurse (Pflegefachkraft) in Germany. But you qualify to work as a Nursing Helper — a role that offers you the chance to gain experience in the German healthcare system. Since this is not a regulated profession, you do not need job recognition to begin working.",
"If your goal is to become a qualified Nurse in Germany, you can take the next step by starting a 3-year nursing training (Pflegeausbildung)."
],
"hero": "A First Step Towards Your Nursing Career, {{name}}",
"nextsteps": [
"Get Help → Free support services for internationally trained professionals.",
"Connect with Employers → Early engagement boosts success; we can help you connect.",
"Improve your German → Recommended at least B1; funding options available."
],
"result": "You can start your journey in Germany as a <em>Nursing Helper</em>"
}
},
"nurse": {
"allmet": {
"explanation": [
"Based on your documents, you meet the requirements to work as a <strong>Nurse (Pflegefachkraft)</strong> in Berlin.",
"This means you're on the right track with job recognition, which is mandatory for international nurses, you can start your professional journey in Germany. <strong>Germany needs skilled nurses like you!</strong>"
],
"hero": "Great News {{name}}",
"nextsteps": [
"Improve your German → You'll need <strong>B2</strong> to work as a nurse in Berlin. Funding options are available."
],
"recognition": [
"🔍 Likely Outcome of Job Recognition: Partial Job Recognition",
"Your qualification aligns <i>partially</i> with German nursing standards — that's a strong foundation!",
"To achieve full recognition and be able to work as a nurse you may need to complete a <strong>compensatory measure</strong> and reach <strong>B2</strong> in German."
],
"result": "You Qualify as a Nurse in Berlin"
},
"missinglicense": {
"explanation": [
"Based on your documents, you already fulfill some of the requirements to work as a <strong>Nurse (Pflegefachkraft)</strong> in Berlin. To move forward, you still need to provide some important documents for your qualification to be recognized in Germany— most importantly, your Nursing <strong>License</strong> or Nursing Registration from your home country.",
"This means you're already on the right track! Please make sure you have all the required documents for job recognition ready once you start the process. There are many free advisory services available to support you along the way."
],
"hero": "Good News {{name}}",
"nextsteps": [
"Improve your German → <strong>B2</strong> target."
],
"recognition": [
"🔍 Likely Outcome of Job Recognition: Partial Job Recognition",
"Your qualification aligns <i>partially</i> with German nursing standards — that's a strong foundation!",
"To achieve full recognition and be able to work as a nurse you may need to complete a <strong>compensatory measure</strong> and reach <strong>B2</strong> in German.",
"Please note: Your professional <strong>license</strong> wasn't uploaded yet. It's a required document for recognition, so make sure you have it available when starting your application."
],
"result": "You May Qualify as a Nurse in Berlin"
},
"missinglicenseandaccreditation": {
"explanation": [
"Based on your documents, you already fulfill some of the requirements to work as a <strong>Nurse (Pflegefachkraft)</strong> in Berlin. To move forward, you still need to provide some important documents for your qualification to be recognized in Germany— most importantly, your Nursing <strong>License</strong> or Nursing Registration from your home country.",
"Also we couldn't verify that your university is <strong>accredited</strong> in your home country. This is mandatory for job recognition.",
"This means you're already on the right track! Please make sure to talk with an advisor about your university accreditation status and have all required documents together before you begin the recognition process. There are many free advisory services available to support you along the way."
],
"hero": "Good News {{name}}",
"nextsteps": [
"Improve your German → <strong>B2</strong> target."
],
"recognition": [
"🔍 Likely Outcome of Job Recognition: Partial Job Recognition",
"Your qualification aligns <i>partially</i> with German nursing standards — that's a strong foundation!",
"To achieve full recognition and be able to work as a nurse you may need to complete a <strong>compensatory measure</strong> and reach <strong>B2</strong> in German.",
"Please note: Your professional license wasn't uploaded yet… You also need to provide proof that your study program is accredited in your home country — without this, job recognition is unlikely."
],
"result": "You May Qualify as a Nurse in Berlin"
}
}
}
},
{
"locale": "es",
"namespace": "cards",
"content": {}
},
{
"locale": "es",
"namespace": "misc",
"content": {}
},
{
"locale": "es",
"namespace": "outputs",
"content": {}
},
{
"locale": "fil",
"namespace": "cards",
"content": {}
},
{
"locale": "fil",
"namespace": "misc",
"content": {}
},
{
"locale": "fil",
"namespace": "outputs",
"content": {}
},
{
"locale": "hi",
"namespace": "cards",
"content": {}
},
{
"locale": "hi",
"namespace": "misc",
"content": {}
},
{
"locale": "hi",
"namespace": "outputs",
"content": {}
},
{
"locale": "sq",
"namespace": "cards",
"content": {}
},
{
"locale": "sq",
"namespace": "misc",
"content": {}
},
{
"locale": "sq",
"namespace": "outputs",
"content": {}
},
{
"locale": "tr",
"namespace": "cards",
"content": {}
},
{
"locale": "tr",
"namespace": "misc",
"content": {}
},
{
"locale": "tr",
"namespace": "outputs",
"content": {}
},
{
"locale": "uk",
"namespace": "cards",
"content": {}
},
{
"locale": "uk",
"namespace": "misc",
"content": {}
},
{
"locale": "uk",
"namespace": "outputs",
"content": {}
},
{
"locale": "vi",
"namespace": "cards",
"content": {}
},
{
"locale": "vi",
"namespace": "misc",
"content": {}
},
{
"locale": "vi",
"namespace": "outputs",
"content": {}
}
];
//# sourceMappingURL=backend-translation-snapshot.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,596 @@
// Snapshot of legacy backend/result translations exported from Google Sheets.
// This file is now the checked-in baseline used to seed and sync DB-backed backend translations.
export type BackendTranslationNamespace = 'cards' | 'misc' | 'outputs'
export interface BackendTranslationSnapshotRow {
locale: string
namespace: BackendTranslationNamespace
content: Record<string, unknown>
}
export const backendTranslationSnapshot: BackendTranslationSnapshotRow[] = [
{
"locale": "ar",
"namespace": "cards",
"content": {}
},
{
"locale": "ar",
"namespace": "misc",
"content": {}
},
{
"locale": "ar",
"namespace": "outputs",
"content": {}
},
{
"locale": "bs",
"namespace": "cards",
"content": {}
},
{
"locale": "bs",
"namespace": "misc",
"content": {}
},
{
"locale": "bs",
"namespace": "outputs",
"content": {}
},
{
"locale": "de",
"namespace": "cards",
"content": {
"additional": {
"copies": {
"description": "Stellen Sie beglaubigte oder zertifizierte Kopien der erforderlichen Dokumente zur Verfügung.",
"requirements": [
"Passkopie",
"Kopien der Bildungsdokumente",
"Kopie des polizeilichen Führungszeugnisses",
"Kopie der Unbedenklichkeitsbescheinigung"
],
"steps": [
"Dokumentenkopien vorbereiten",
"Bei Bedarf von vereidigtem Übersetzer übersetzen lassen"
],
"title": "Kopien"
},
"experience": {
"description": "Laden Sie Ihren Lebenslauf auf unsere Plattform hoch, um sich mit Arbeitgebern zu verbinden.",
"requirements": [
"Lebenslauf (CV)"
],
"steps": [
"Erstellen Sie Ihren Lebenslauf",
"Laden Sie ihn in Ihr Profil hoch"
],
"title": "Berufserfahrung"
},
"originals": {
"description": "Bereiten Sie erforderliche Originale für die Einreichung bei der Behörde vor.",
"requirements": [
"Ausgefülltes Antragsformular",
"Lebenslauf (unterschrieben und datiert)",
"Ärztliches Attest",
"Führungszeugnis"
],
"steps": [
"Originale vorbereiten",
"Bei der Behörde einreichen"
],
"title": "Originale"
}
},
"education": {
"completeddescription": "Abgeschlossenes und anerkanntes Pflegeausbildungsprogramm.",
"requireddescription": "Pflegeausbildungsprogramm nicht abgeschlossen oder anerkannt.",
"requirements": [
"Pflegeabschluss",
"Anerkanntes Studienprogramm",
"Strukturierte Ausbildung"
],
"steps": {
"missaccredited": "Ihre Universität ist möglicherweise nicht akkreditiert, aber ein Berater oder LaGeSo kann Ihnen helfen herauszufinden, ob Sie sich dennoch für eine Berufsanerkennung qualifizieren.",
"missdegree": "Ihr Abschluss entspricht nicht dem Pflegeberuf in Deutschland, aber Sie könnten sich für eine andere Rolle qualifizieren. Ein Berater kann Ihnen helfen, den Beruf zu finden, der am besten zu Ihrem Hintergrund passt.",
"missstructured": "Ihre Ausbildungsstruktur entspricht nicht den deutschen Anforderungen, aber ein Berater oder LaGeSo kann Ihnen helfen, Ihre Möglichkeiten für den Beginn einer Pflegelaufbahn in Deutschland zu erkunden."
}
},
"language": {
"description": "Nachweis der erforderlichen deutschen Sprachkenntnisse.",
"levelnames": {
"none": "KEINES"
},
"reminder": "Erinnerung: Sie müssen Ihr Deutsch-Niveau mit einem Zertifikat nachweisen.",
"requirements": {
"certificate": "Sprachzertifikat (Goethe-Institut, telc, TestDaF oder ECL)",
"germanlevel": "Deutsch Niveau {level}"
},
"steps": {
"getcertificate": "Erhalten Sie ein Sprachzertifikat vom Goethe-Institut, telc, TestDaF oder ECL",
"improvelevel": "Verbessern Sie Ihr Deutsch-Niveau von {currentLevel} auf {requiredLevel}"
},
"title": "Deutsche Sprachkenntnisse"
},
"license": {
"descriptioncompleted": "Gültige Pflegelizenz oder -registrierung aus Ihrem Heimatland.",
"descriptionrequired": "Fehlende Pflegelizenz oder -registrierung aus Ihrem Heimatland.",
"requirements": [
"Pflegelizenz aus Ihrem Heimatland erforderlich für Berufsanerkennung"
],
"steps": [
"Beschaffen Sie Ihre Lizenz aus Ihrem Heimatland"
]
}
}
},
{
"locale": "de",
"namespace": "misc",
"content": {
"notes": {
"visanote": "Bitte beachten Sie, dass Sie ein B1-Deutschzertifikat benötigen, um ein Visum für Deutschland zu erhalten."
}
}
},
{
"locale": "de",
"namespace": "outputs",
"content": {
"assistant": {
"allmet": {
"explanation": [
"Basierend auf Ihren Dokumenten erfüllen Sie die Voraussetzungen, um als Pflegeassistent (Pflegefachassistenz) in Berlin zu arbeiten.",
"Das bedeutet, Sie sind auf dem richtigen Weg mit der Berufsanerkennung, die für internationale Pflegekräfte obligatorisch ist, können Sie Ihre berufliche Laufbahn in Deutschland beginnen. Deutschland braucht qualifizierte Pflegekräfte wie Sie!"
],
"hero": "Ein erster Schritt zu Ihrer Pflegelaufbahn, {{name}}",
"nextsteps": [
"Reichen Sie Ihren Antrag ein → Beantragen Sie die Erlaubnis zur Führung der Berufsbezeichnung \\\"Pflegeassistent (Pflegefachassistenz)\\\" bei LaGeSo (stellen Sie vollständige Dokumente sicher).",
"Verbessern Sie Ihr Deutsch → <strong>B1</strong> Ziel."
],
"recognition": [
"🔍 Wahrscheinliches Ergebnis der Berufsanerkennung: Teilweise Berufsanerkennung",
"Ihre Qualifikation entspricht <i>teilweise</i> den deutschen Pflegestandards das ist eine starke Grundlage!",
"Um eine vollständige Anerkennung zu erreichen und als Pflegekraft arbeiten zu können, müssen Sie möglicherweise eine <strong>Ausgleichsmaßnahme</strong> absolvieren und <strong>B1</strong> in Deutsch erreichen."
],
"result": "Sie qualifizieren sich als Pflegeassistent in Berlin"
},
"missinglicense": {
"explanation": [
"Basierend auf Ihren Dokumenten erfüllen Sie bereits einige der Voraussetzungen, um als Pflegeassistent (Pflegefachassistenz) in Berlin zu arbeiten. Um voranzukommen, müssen Sie noch einige wichtige Dokumente für die Anerkennung Ihrer Qualifikation in Deutschland bereitstellen am wichtigsten ist Ihre Pflegelizenz oder Pflegeregistrierung aus Ihrem Heimatland.",
"Das bedeutet, Sie sind bereits auf dem richtigen Weg! Bitte stellen Sie sicher, dass Sie alle erforderlichen Dokumente für die Berufsanerkennung bereit haben, sobald Sie den Prozess starten. Es gibt viele kostenlose Beratungsdienste, die Sie dabei unterstützen."
],
"hero": "Ein erster Schritt zu Ihrer Pflegelaufbahn, {{name}}",
"nextsteps": [
"Reichen Sie Ihren Antrag ein → LaGeSo.",
"Verbessern Sie Ihr Deutsch → <strong>B1</strong> Ziel."
],
"recognition": [
"🔍 Wahrscheinliches Ergebnis der Berufsanerkennung: Teilweise Berufsanerkennung",
"Um eine vollständige Anerkennung zu erreichen und als Pflegekraft arbeiten zu können, benötigen Sie möglicherweise eine Ausgleichsmaßnahme und B1 Deutsch.",
"Bitte beachten Sie: Ihre berufliche Lizenz wurde noch nicht hochgeladen. Es ist ein erforderliches Dokument für die Anerkennung."
],
"result": "Sie könnten sich als Pflegeassistent in Berlin qualifizieren"
},
"missinglicenseandaccreditation": {
"explanation": [
"Basierend auf Ihren Dokumenten erfüllen Sie bereits einige der Voraussetzungen, um als Pflegeassistent (Pflegefachassistenz) in Berlin zu arbeiten. Um voranzukommen, müssen Sie noch einige wichtige Dokumente bereitstellen am wichtigsten ist Ihre Pflegelizenz oder Pflegeregistrierung aus Ihrem Heimatland.",
"Außerdem konnten wir nicht verifizieren, dass Ihre Universität in Ihrem Heimatland <strong>akkreditiert</strong> ist. Dies ist für die Berufsanerkennung obligatorisch.",
"Das bedeutet, Sie sind bereits auf dem richtigen Weg! Bitte sprechen Sie mit einem Berater über den Akkreditierungsstatus Ihrer Universität und sammeln Sie alle erforderlichen Dokumente, bevor Sie den Anerkennungsprozess beginnen."
],
"hero": "Ein erster Schritt zu Ihrer Pflegelaufbahn, {{name}}",
"nextsteps": [
"Reichen Sie Ihren Antrag ein → LaGeSo.",
"Verbessern Sie Ihr Deutsch → <strong>B1</strong> Ziel."
],
"recognition": [
"🔍 Wahrscheinliches Ergebnis der Berufsanerkennung: Teilweise Berufsanerkennung",
"B1 Deutsch wahrscheinlich erforderlich.",
"Bitte beachten Sie: Ihre berufliche Lizenz wurde noch nicht hochgeladen. Sie müssen auch nachweisen, dass Ihr Studienprogramm akkreditiert ist ohne dies ist eine Berufsanerkennung unwahrscheinlich."
],
"result": "Sie könnten sich als Pflegeassistent in Berlin qualifizieren"
}
},
"helper": {
"default": {
"explanation": [
"Ihre Dokumente zeigen, dass Sie noch nicht alle Voraussetzungen erfüllen, um als Pflegefachkraft in Deutschland zu arbeiten. Aber Sie qualifizieren sich, als Pflegehelfer zu arbeiten eine Rolle, die Ihnen die Möglichkeit bietet, Erfahrungen im deutschen Gesundheitssystem zu sammeln. Da dies kein reglementierter Beruf ist, benötigen Sie keine Berufsanerkennung, um mit der Arbeit zu beginnen.",
"Wenn Ihr Ziel ist, eine qualifizierte Pflegekraft in Deutschland zu werden, können Sie den nächsten Schritt machen, indem Sie eine 3-jährige Pflegeausbildung (Pflegeausbildung) beginnen."
],
"hero": "Ein erster Schritt zu Ihrer Pflegelaufbahn, {{name}}",
"nextsteps": [
"Hilfe holen → Kostenlose Unterstützungsdienste für international ausgebildete Fachkräfte.",
"Mit Arbeitgebern vernetzen → Frühes Engagement erhöht den Erfolg; wir können Ihnen beim Vernetzen helfen.",
"Verbessern Sie Ihr Deutsch → Empfohlen mindestens B1; Finanzierungsmöglichkeiten verfügbar."
],
"result": "Sie können Ihre Reise in Deutschland als <em>Pflegehelfer</em> beginnen"
}
},
"nurse": {
"allmet": {
"explanation": [
"Basierend auf Ihren Dokumenten erfüllen Sie die Voraussetzungen, um als <strong>Pflegefachkraft</strong> in Berlin zu arbeiten.",
"Das bedeutet, Sie sind auf dem richtigen Weg mit der Berufsanerkennung, die für internationale Pflegekräfte obligatorisch ist, können Sie Ihre berufliche Laufbahn in Deutschland beginnen. <strong>Deutschland braucht qualifizierte Pflegekräfte wie Sie!</strong>"
],
"hero": "Großartige Neuigkeiten {{name}}",
"nextsteps": [
"Verbessern Sie Ihr Deutsch → Sie benötigen <strong>B2</strong>, um als Pflegekraft in Berlin zu arbeiten. Finanzierungsmöglichkeiten sind verfügbar."
],
"recognition": [
"🔍 Wahrscheinliches Ergebnis der Berufsanerkennung: Teilweise Berufsanerkennung",
"Ihre Qualifikation entspricht <i>teilweise</i> den deutschen Pflegestandards das ist eine starke Grundlage!",
"Um eine vollständige Anerkennung zu erreichen und als Pflegekraft arbeiten zu können, müssen Sie möglicherweise eine <strong>Ausgleichsmaßnahme</strong> absolvieren und <strong>B2</strong> in Deutsch erreichen."
],
"result": "Sie qualifizieren sich als Pflegekraft in Berlin"
},
"missinglicense": {
"explanation": [
"Basierend auf Ihren Dokumenten erfüllen Sie bereits einige der Voraussetzungen, um als <strong>Pflegefachkraft</strong> in Berlin zu arbeiten. Um voranzukommen, müssen Sie noch einige wichtige Dokumente für die Anerkennung Ihrer Qualifikation in Deutschland bereitstellen am wichtigsten ist Ihre Pflege-<strong>Lizenz</strong> oder Pflegeregistrierung aus Ihrem Heimatland.",
"Das bedeutet, Sie sind bereits auf dem richtigen Weg! Bitte stellen Sie sicher, dass Sie alle erforderlichen Dokumente für die Berufsanerkennung bereit haben, sobald Sie den Prozess starten. Es gibt viele kostenlose Beratungsdienste, die Sie dabei unterstützen."
],
"hero": "Gute Neuigkeiten {{name}}",
"nextsteps": [
"Verbessern Sie Ihr Deutsch → <strong>B2</strong> Ziel."
],
"recognition": [
"🔍 Wahrscheinliches Ergebnis der Berufsanerkennung: Teilweise Berufsanerkennung",
"Ihre Qualifikation entspricht <i>teilweise</i> den deutschen Pflegestandards das ist eine starke Grundlage!",
"Um eine vollständige Anerkennung zu erreichen und als Pflegekraft arbeiten zu können, müssen Sie möglicherweise eine <strong>Ausgleichsmaßnahme</strong> absolvieren und <strong>B2</strong> in Deutsch erreichen.",
"Bitte beachten Sie: Ihre berufliche <strong>Lizenz</strong> wurde noch nicht hochgeladen. Es ist ein erforderliches Dokument für die Anerkennung, stellen Sie also sicher, dass Sie es zur Verfügung haben, wenn Sie Ihren Antrag stellen."
],
"result": "Sie könnten sich als Pflegekraft in Berlin qualifizieren"
},
"missinglicenseandaccreditation": {
"explanation": [
"Basierend auf Ihren Dokumenten erfüllen Sie bereits einige der Voraussetzungen, um als <strong>Pflegefachkraft</strong> in Berlin zu arbeiten. Um voranzukommen, müssen Sie noch einige wichtige Dokumente für die Anerkennung Ihrer Qualifikation in Deutschland bereitstellen am wichtigsten ist Ihre Pflege-<strong>Lizenz</strong> oder Pflegeregistrierung aus Ihrem Heimatland.",
"Außerdem konnten wir nicht verifizieren, dass Ihre Universität in Ihrem Heimatland <strong>akkreditiert</strong> ist. Dies ist für die Berufsanerkennung obligatorisch.",
"Das bedeutet, Sie sind bereits auf dem richtigen Weg! Bitte sprechen Sie unbedingt mit einem Berater über den Akkreditierungsstatus Ihrer Universität und haben Sie alle erforderlichen Dokumente zusammen, bevor Sie den Anerkennungsprozess beginnen. Es gibt viele kostenlose Beratungsdienste, die Sie dabei unterstützen."
],
"hero": "Gute Neuigkeiten {{name}}",
"nextsteps": [
"Verbessern Sie Ihr Deutsch → <strong>B2</strong> Ziel."
],
"recognition": [
"🔍 Wahrscheinliches Ergebnis der Berufsanerkennung: Teilweise Berufsanerkennung",
"Ihre Qualifikation entspricht <i>teilweise</i> den deutschen Pflegestandards das ist eine starke Grundlage!",
"Um eine vollständige Anerkennung zu erreichen und als Pflegekraft arbeiten zu können, müssen Sie möglicherweise eine <strong>Ausgleichsmaßnahme</strong> absolvieren und <strong>B2</strong> in Deutsch erreichen.",
"Bitte beachten Sie: Ihre berufliche Lizenz wurde noch nicht hochgeladen… Sie müssen auch nachweisen, dass Ihr Studienprogramm in Ihrem Heimatland akkreditiert ist ohne dies ist eine Berufsanerkennung unwahrscheinlich."
],
"result": "Sie könnten sich als Pflegekraft in Berlin qualifizieren"
}
}
}
},
{
"locale": "en",
"namespace": "cards",
"content": {
"additional": {
"copies": {
"description": "Provide notarized or certified copies of required documents.",
"requirements": [
"Passport copy",
"Education documents copies",
"Police clearance certificate copy",
"Certificate of good standing copy"
],
"steps": [
"Prepare document copies",
"Translate by sworn translator if required"
],
"title": "Copies"
},
"experience": {
"description": "Upload your CV to our platform to connect with employers.",
"requirements": [
"Curriculum Vitae (CV)"
],
"steps": [
"Create your CV",
"Upload it to your profile"
],
"title": "Work Experience"
},
"originals": {
"description": "Prepare required originals for submission to the authority.",
"requirements": [
"Completed application form",
"CV (signed and dated)",
"Medical fitness certificate",
"Certificate of good conduct"
],
"steps": [
"Prepare originals",
"Submit to the authority"
],
"title": "Originals"
}
},
"education": {
"completeddescription": "Completed and approved nursing education program.",
"requireddescription": "Nursing education program not completed or approved.",
"requirements": [
"Nursing degree",
"Accredited study program",
"Structured training"
],
"steps": {
"missaccredited": "Your university may not be accredited, but an advisor or LaGeSo can help you find out whether you still qualify for job recognition.",
"missdegree": "Your degree doesn't match the nursing profession in Germany, but you could qualify for another role. An advisor can help you find the profession that best fits your background.",
"missstructured": "Your education structure does not match German requirements, but an advisor or LaGeSo can help you explore your options for starting a nursing career in Germany."
}
},
"language": {
"description": "Demonstrate required German language skills.",
"levelnames": {
"none": "NONE"
},
"reminder": "Reminder: you will need to prove your German level with a Certificate.",
"requirements": {
"certificate": "Language certificate (Goethe-Institut, telc, TestDaF, or ECL)",
"germanlevel": "German level {level}"
},
"steps": {
"getcertificate": "Get a language certificate from Goethe-Institut, telc, TestDaF, or ECL",
"improvelevel": "Improve your German level from {currentLevel} to {requiredLevel}"
},
"title": "German Proficiency"
},
"license": {
"descriptioncompleted": "Valid nursing license or registration from your home country.",
"descriptionrequired": "Missing nursing license or registration from your home country.",
"requirements": [
"Nursing license from your home country required for job recognition"
],
"steps": [
"Obtain your license from your home country"
]
}
}
},
{
"locale": "en",
"namespace": "misc",
"content": {
"notes": {
"visanote": "Please note that you need a B1 German language certificate in order to obtain a visa for Germany."
}
}
},
{
"locale": "en",
"namespace": "outputs",
"content": {
"assistant": {
"allmet": {
"explanation": [
"Based on your documents, you meet the requirements to work as a Nursing Assistant (Pflegefachassistenz) in Berlin.",
"This means you're on the right track with job recognition, which is mandatory for international nurses, you can start your professional journey in Germany. Germany needs skilled nurses like you!"
],
"hero": "A First Step Towards Your Nursing Career, {{name}}",
"nextsteps": [
"Submit your application → Apply for permission to use the title \"Assistant Nurse (Pflegefachassistenz)\" at LaGeSo (ensure complete docs).",
"Improve your German → <strong>B1</strong> target."
],
"recognition": [
"🔍 Likely Outcome of Job Recognition: Partial Job Recognition",
"Your qualification aligns <i>partially</i> with German nursing standards — that's a strong foundation!",
"To achieve full recognition and be able to work as a nurse you may need to complete a <strong>compensatory measure</strong> and reach <strong>B1</strong> in German."
],
"result": "You Qualify as a Nursing Assistant in Berlin"
},
"missinglicense": {
"explanation": [
"Based on your documents, you already fulfill some of the requirements to work as a Nursing Assistant (Pflegefachassistenz) in Berlin. To move forward, you still need to provide some important documents for your qualification to be recognized in Germany— most importantly, your Nursing License or Nursing Registration from your home country.",
"This means you're already on the right track! Please make sure you have all the required documents for job recognition ready once you start the process. There are many free advisory services available to support you along the way."
],
"hero": "A First Step Towards Your Nursing Career, {{name}}",
"nextsteps": [
"Submit your application → LaGeSo.",
"Improve your German → <strong>B1</strong> target."
],
"recognition": [
"🔍 Likely Outcome of Job Recognition: Partial Job Recognition",
"To achieve full recognition and be able to work as a nurse you may need a compensatory measure and B1 German.",
"Please note: Your professional license wasn't uploaded yet. It's a required document for recognition."
],
"result": "You May Qualify as a Nursing Assistant in Berlin"
},
"missinglicenseandaccreditation": {
"explanation": [
"Based on your documents, you already fulfill some of the requirements to work as a Nursing Assistant (Pflegefachassistenz) in Berlin. To move forward, you still need to provide some important documents — most importantly, your Nursing License or Nursing Registration from your home country.",
"Also we couldn't verify that your university is <strong>accredited</strong> in your home country. This is mandatory for job recognition.",
"This means you're already on the right track! Please talk with an advisor about your university accreditation status and gather all required documents before beginning the recognition process."
],
"hero": "A First Step Towards Your Nursing Career, {{name}}",
"nextsteps": [
"Submit your application → LaGeSo.",
"Improve your German → <strong>B1</strong> target."
],
"recognition": [
"🔍 Likely Outcome of Job Recognition: Partial Job Recognition",
"B1 German likely required.",
"Please note: Your professional license wasn't uploaded yet. You also need to provide proof that your study program is accredited — without this, job recognition is unlikely."
],
"result": "You May Qualify as a Nursing Assistant in Berlin"
}
},
"helper": {
"default": {
"explanation": [
"Your documents show that you don't yet meet all the requirements to work as a Nurse (Pflegefachkraft) in Germany. But you qualify to work as a Nursing Helper — a role that offers you the chance to gain experience in the German healthcare system. Since this is not a regulated profession, you do not need job recognition to begin working.",
"If your goal is to become a qualified Nurse in Germany, you can take the next step by starting a 3-year nursing training (Pflegeausbildung)."
],
"hero": "A First Step Towards Your Nursing Career, {{name}}",
"nextsteps": [
"Get Help → Free support services for internationally trained professionals.",
"Connect with Employers → Early engagement boosts success; we can help you connect.",
"Improve your German → Recommended at least B1; funding options available."
],
"result": "You can start your journey in Germany as a <em>Nursing Helper</em>"
}
},
"nurse": {
"allmet": {
"explanation": [
"Based on your documents, you meet the requirements to work as a <strong>Nurse (Pflegefachkraft)</strong> in Berlin.",
"This means you're on the right track with job recognition, which is mandatory for international nurses, you can start your professional journey in Germany. <strong>Germany needs skilled nurses like you!</strong>"
],
"hero": "Great News {{name}}",
"nextsteps": [
"Improve your German → You'll need <strong>B2</strong> to work as a nurse in Berlin. Funding options are available."
],
"recognition": [
"🔍 Likely Outcome of Job Recognition: Partial Job Recognition",
"Your qualification aligns <i>partially</i> with German nursing standards — that's a strong foundation!",
"To achieve full recognition and be able to work as a nurse you may need to complete a <strong>compensatory measure</strong> and reach <strong>B2</strong> in German."
],
"result": "You Qualify as a Nurse in Berlin"
},
"missinglicense": {
"explanation": [
"Based on your documents, you already fulfill some of the requirements to work as a <strong>Nurse (Pflegefachkraft)</strong> in Berlin. To move forward, you still need to provide some important documents for your qualification to be recognized in Germany— most importantly, your Nursing <strong>License</strong> or Nursing Registration from your home country.",
"This means you're already on the right track! Please make sure you have all the required documents for job recognition ready once you start the process. There are many free advisory services available to support you along the way."
],
"hero": "Good News {{name}}",
"nextsteps": [
"Improve your German → <strong>B2</strong> target."
],
"recognition": [
"🔍 Likely Outcome of Job Recognition: Partial Job Recognition",
"Your qualification aligns <i>partially</i> with German nursing standards — that's a strong foundation!",
"To achieve full recognition and be able to work as a nurse you may need to complete a <strong>compensatory measure</strong> and reach <strong>B2</strong> in German.",
"Please note: Your professional <strong>license</strong> wasn't uploaded yet. It's a required document for recognition, so make sure you have it available when starting your application."
],
"result": "You May Qualify as a Nurse in Berlin"
},
"missinglicenseandaccreditation": {
"explanation": [
"Based on your documents, you already fulfill some of the requirements to work as a <strong>Nurse (Pflegefachkraft)</strong> in Berlin. To move forward, you still need to provide some important documents for your qualification to be recognized in Germany— most importantly, your Nursing <strong>License</strong> or Nursing Registration from your home country.",
"Also we couldn't verify that your university is <strong>accredited</strong> in your home country. This is mandatory for job recognition.",
"This means you're already on the right track! Please make sure to talk with an advisor about your university accreditation status and have all required documents together before you begin the recognition process. There are many free advisory services available to support you along the way."
],
"hero": "Good News {{name}}",
"nextsteps": [
"Improve your German → <strong>B2</strong> target."
],
"recognition": [
"🔍 Likely Outcome of Job Recognition: Partial Job Recognition",
"Your qualification aligns <i>partially</i> with German nursing standards — that's a strong foundation!",
"To achieve full recognition and be able to work as a nurse you may need to complete a <strong>compensatory measure</strong> and reach <strong>B2</strong> in German.",
"Please note: Your professional license wasn't uploaded yet… You also need to provide proof that your study program is accredited in your home country — without this, job recognition is unlikely."
],
"result": "You May Qualify as a Nurse in Berlin"
}
}
}
},
{
"locale": "es",
"namespace": "cards",
"content": {}
},
{
"locale": "es",
"namespace": "misc",
"content": {}
},
{
"locale": "es",
"namespace": "outputs",
"content": {}
},
{
"locale": "fil",
"namespace": "cards",
"content": {}
},
{
"locale": "fil",
"namespace": "misc",
"content": {}
},
{
"locale": "fil",
"namespace": "outputs",
"content": {}
},
{
"locale": "hi",
"namespace": "cards",
"content": {}
},
{
"locale": "hi",
"namespace": "misc",
"content": {}
},
{
"locale": "hi",
"namespace": "outputs",
"content": {}
},
{
"locale": "sq",
"namespace": "cards",
"content": {}
},
{
"locale": "sq",
"namespace": "misc",
"content": {}
},
{
"locale": "sq",
"namespace": "outputs",
"content": {}
},
{
"locale": "tr",
"namespace": "cards",
"content": {}
},
{
"locale": "tr",
"namespace": "misc",
"content": {}
},
{
"locale": "tr",
"namespace": "outputs",
"content": {}
},
{
"locale": "uk",
"namespace": "cards",
"content": {}
},
{
"locale": "uk",
"namespace": "misc",
"content": {}
},
{
"locale": "uk",
"namespace": "outputs",
"content": {}
},
{
"locale": "vi",
"namespace": "cards",
"content": {}
},
{
"locale": "vi",
"namespace": "misc",
"content": {}
},
{
"locale": "vi",
"namespace": "outputs",
"content": {}
}
]

View File

@@ -0,0 +1,36 @@
import type {
EmailService,
SendEmailInput,
SendEmailResult,
} from '@pikku/core/services'
import {
renderEmailTemplate,
type EmailTemplateName,
type RenderEmailInput,
} from '../../.pikku/email/pikku-emails.gen.js'
export class ProjectEmailService implements EmailService {
constructor(private readonly delegate: EmailService) {}
async send(input: SendEmailInput): Promise<SendEmailResult> {
if (!('template' in input) || !input.template) {
return this.delegate.send(input)
}
const rendered = renderEmailTemplate(
input.template as RenderEmailInput<EmailTemplateName>
)
return this.delegate.send({
to: input.to,
from: input.from,
cc: input.cc,
bcc: input.bcc,
replyTo: input.replyTo,
headers: input.headers,
subject: input.subject || rendered.subject,
html: rendered.html,
...(rendered.text ? { text: rendered.text } : {}),
})
}
}

View File

@@ -0,0 +1,66 @@
import Mailgun from 'mailgun.js'
import formData from 'form-data'
import type {
EmailService,
SendEmailInput,
SendEmailResult,
} from '@pikku/core/services'
const isProduction = process.env.NODE_ENV === 'production'
export class MailgunEmailService implements EmailService {
private readonly mg: ReturnType<Mailgun['client']> | undefined
constructor(
apiKey: string | undefined,
private readonly domain: string,
) {
if (apiKey) {
const mailgun = new Mailgun(formData)
this.mg = mailgun.client({
username: 'api',
key: apiKey,
url: 'https://api.eu.mailgun.net',
})
}
}
async send(input: SendEmailInput): Promise<SendEmailResult> {
if ('template' in input) {
throw new Error('MailgunEmailService expects rendered email content, not template references')
}
if (!isProduction || !this.mg) {
const recipients = normalizeRecipients(input.to)
console.log(
`Would have sent email to ${recipients.join(', ')} with subject "${input.subject || ''}"`
)
return {}
}
const result = await this.mg.messages.create('mail.hey-germany.com', {
from: input.from || `HeyGermany <info@${this.domain}>`,
to: normalizeRecipients(input.to),
cc: normalizeRecipients(input.cc),
bcc: normalizeRecipients(input.bcc),
subject: input.subject,
html: 'html' in input ? input.html : undefined,
text: 'text' in input ? input.text : undefined,
h: input.headers,
} as any)
return {
messageId: typeof result === 'object' && result && 'id' in result
? String(result.id)
: undefined,
}
}
}
const normalizeRecipients = (value?: string | string[]) => {
if (!value) {
return []
}
return Array.isArray(value) ? value : [value]
}

View File

@@ -0,0 +1,238 @@
<!doctype html>
<html lang="en" dir="ltr" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
</style>
<style type="text/css">
</style>
</head>
<body style="word-spacing:normal;background-color:#F4F4F4;">
<div style="background-color:#F4F4F4;" role="main" lang="en" dir="ltr">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tbody>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<h1 class="text-build-content" style="margin-top: 10px; margin-bottom: 10px"><span
style="color:#5e6977;font-family: 'Trebuchet MS';font-size:28px;line-height:22px;">Hi {{
firstname }},</span></h1>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content" style="line-height:21px;"><span
style="color:#5e6977;font-family:Arial;font-size:15px;">Your previous link has expired —
here's a new one to access your evaluation:</span></p>
</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:separate;line-height:100%;">
<tbody>
<tr>
<td align="center" bgcolor="#ebb305" role="presentation"
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#ebb305;"
valign="middle">
<a href="{{magicLink}}"
style="display:inline-block;background:#ebb305;color:#000000;font-family:Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;">
Get my result
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
{{ctaFollowUpHtml}}
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"><span
style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">For security
reasons, this link is valid for 7 days. You can always log in at <a href="https://hey-germany.com">hey-germany.com</a> to
request a new link anytime.</span></p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span
style="color:#5e6977;font-family: Arial;font-size:15px;line-height:22px;">Warm
regards,</span></p>
<p class="text-build-content" style="margin: 10px 0;"><span
style="color:#5e6977;font-family: Arial;font-size:15px;line-height:22px;">Your HeyGermany
Team</span></p>
<p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span
style="color:#5e6977;font-family: Arial;font-size:10px;line-height:22px;">HeyGermany ·
Helping international nurses start their careers in Germany · www.hey-germany.com</span>
</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,193 @@
<!doctype html>
<html lang="en" dir="ltr" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 { width:100% !important; max-width: 100%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
</style>
<style type="text/css">
[owa] .mj-column-per-100 { width:100% !important; max-width: 100%; }
</style>
<style type="text/css">
</style>
<style type="text/css">
</style>
</head>
<body style="word-spacing:normal;background-color:#F4F4F4;">
<div
style="background-color:#F4F4F4;" role="main" lang="en" dir="ltr"
>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><h1 class="text-build-content" style="margin-top: 10px; margin-bottom: 10px"><span style="color:#5e6977;font-family: 'Trebuchet MS';font-size:28px;line-height:22px;">Hi {{ firstname }},</span></h1></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0;; margin-top: 10px"><span style="color:#5e6977;font-family: Arial;font-size:15px;line-height:22px;">Welcome to HeyGermany — we're excited to help you take the next step toward your nursing career in Germany! 🌍💙</span></p><p class="text-build-content" style="margin: 10px 0;"><span style="color:#5e6977;font-family: Arial;font-size:15px;line-height:22px;">Before we can begin with your personal evaluation, please confirm your email address.</span></p><p class="text-build-content" style="margin: 10px 0;; margin-bottom: 10px"><span style="color:#5e6977;font-family: Arial;font-size:15px;line-height:22px;">👉 Click below to confirm your email and get started:</span></p></div>
</td>
</tr>
<tr>
<td
align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"
>
<tbody>
<tr>
<td
align="center" bgcolor="#ebb305" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#ebb305;" valign="middle"
>
<a
href="{{verificationLink}}" style="display:inline-block;background:#ebb305;color:#000000;font-family:Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
>
Confirm my email
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
>{{ctaFollowUpHtml}}</div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0;; margin-top: 10px; margin-bottom: 10px"><span style="color:#5e6977;font-family: Arial;font-size:15px;line-height:22px;">We're thrilled to support you on your way to becoming a nurse in Germany — let's make it happen! ✨</span></p></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0;; margin-top: 10px"><span style="color:#5e6977;font-family: Arial;font-size:15px;line-height:22px;">Warm regards,</span></p><p class="text-build-content" style="margin: 10px 0;; margin-bottom: 10px"><span style="color:#5e6977;font-family: Arial;font-size:15px;line-height:22px;">Your HeyGermany Team</span><br><br><span style="color:#5e6977;font-family: Arial;font-size:10px;line-height:22px;">HeyGermany · Helping international nurses start their careers in Germany · www.hey-germany.com</span><br>&nbsp;</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,181 @@
<!doctype html>
<html lang="en" dir="ltr" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 { width:100% !important; max-width: 100%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
</style>
<style type="text/css">
[owa] .mj-column-per-100 { width:100% !important; max-width: 100%; }
</style>
<style type="text/css">
</style>
<style type="text/css">
</style>
</head>
<body style="word-spacing:normal;background-color:#F4F4F4;">
<div
style="background-color:#F4F4F4;" role="main" lang="en" dir="ltr"
>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><h1 class="text-build-content" style="margin-top: 10px; margin-bottom: 10px"><span style="color:#5e6977;font-family: 'Trebuchet MS';font-size:28px;line-height:22px;">Hi {{ firstname }},</span></h1></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">Thanks for uploading your documents!</span></p><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">We've reviewed your files, but unfortunately, one or more of them couldn't be processed for your personal evaluation.</span></p><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">This can happen if:</span></p><ul><li><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">The document is incomplete or blurry</span></li><li><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">The content is unclear or unreadable</span></li><li><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">The file you uploaded is not related to the document we requested</span></li></ul><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">Please upload a clear and complete version of the missing or invalid document(s) so we can continue your evaluation.</span></p></div>
</td>
</tr>
<tr>
<td
align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"
>
<tbody>
<tr>
<td
align="center" bgcolor="#ebb305" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#ebb305;" valign="middle"
>
<a
href="https://hey-germany.com" style="display:inline-block;background:#ebb305;color:#000000;font-family:Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"
>
Upload documents again
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:18px;">If you're not sure which file needs to be replaced, just reply to this email — we're happy to help!</span></p></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">Warm regards,</span></p><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">Your HeyGermany Team</span><br><br><span style="color:#5e6977;font-family:Arial;font-size:10px;line-height:22px;">HeyGermany · Helping international nurses start their careers in Germany · www.hey-germany.com</span></p><p class="text-build-content">&nbsp;</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,219 @@
<!doctype html>
<html lang="en" dir="ltr" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 { width:100% !important; max-width: 100%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
</style>
<style type="text/css">
[owa] .mj-column-per-100 { width:100% !important; max-width: 100%; }
</style>
<style type="text/css">
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
</style>
<style type="text/css">
</style>
</head>
<body style="word-spacing:normal;background-color:#F4F4F4;">
<div
style="background-color:#F4F4F4;" role="main" lang="en" dir="ltr"
>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><h1 class="text-build-content"><span style="color:#5e6977;font-family:Trebuchet MS;font-size:28px;line-height:22px;">Hi {{ firstname }},</span></h1></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">We noticed you started your application but didn't finish uploading all your documents.
</span></p><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">Once your upload is complete, we can create your free personal evaluation and show you how your nursing qualification compares to German standards.
</span></p><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">You will get:
</span></p><ul><li><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">A free evaluation to see if you can work as a nurse in Germany
</span></li><li><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">Guidance on your next steps toward job recognition
</span></li><li><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">The opportunity to connect with German employers and find your nursing job
</span></li></ul><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">It only takes a few minutes — and it's completely free.
</span></p></div>
</td>
</tr>
<tr>
<td
align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"
>
<tbody>
<tr>
<td
align="center" bgcolor="#ebb305" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#ebb305;" valign="middle"
>
<a
href="https://hey-germany.com" style="display:inline-block;background:#ebb305;color:#000000;font-family:Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"
>
Check my qualifications
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">Your future as a nurse in Germany starts here. Complete your upload today and take the next step toward your career in Germany!
</span></p><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">We're here if you need help at any point — just reply to this email.
</span></p></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">Warm regards,</span></p><p class="text-build-content"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px;">Your HeyGermany Team</span></p><p class="text-build-content"><br><span style="color:#5e6977;font-family:Arial;font-size:10px;line-height:22px;">HeyGermany · Helping international nurses start their careers in Germany · www.hey-germany.com</span></p></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:20px;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
>
<tbody>
<tr>
<td style="width:190px;">
<img
alt="" src="https://191n.mj.am/tplimg/191n/b/xrkg/ng20.png" style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" title="" width="190" height="auto"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,181 @@
<!doctype html>
<html lang="en" dir="ltr" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 { width:100% !important; max-width: 100%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
</style>
<style type="text/css">
[owa] .mj-column-per-100 { width:100% !important; max-width: 100%; }
</style>
<style type="text/css">
</style>
<style type="text/css">
</style>
</head>
<body style="word-spacing:normal;background-color:#F4F4F4;">
<div
style="background-color:#F4F4F4;" role="main" lang="en" dir="ltr"
>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"
>
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"
>
<tbody>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><h1 class="text-build-content" style="margin-top: 10px; margin-bottom: 10px"><span style="color:#5e6977;font-family:'Trebuchet MS';font-size:28px;line-height:22px">Hi {{ firstname }},</span></h1></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px">Great news — your personal evaluation is ready!</span></p><p class="text-build-content" style="margin: 10px 0;"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px">You can now see how your nursing qualification compares to German standards and discover your next steps toward working as a nurse in Germany.</span></p><p class="text-build-content" style="margin: 10px 0;"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px">👉 Click the button below to view your results:</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;">&nbsp;</p></div>
</td>
</tr>
<tr>
<td
align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;"
>
<table
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"
>
<tbody>
<tr>
<td
align="center" bgcolor="#ebb305" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#ebb305;" valign="middle"
>
<a
href="{{magicLink}}" style="display:inline-block;background:#ebb305;color:#000000;font-family:Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
>
Get my result
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px">This is your personal access link. For your security, the link will expire in 7 days.</span></p><p class="text-build-content" style="margin: 10px 0;"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px">After that, you can access your personal evaluation and job recognition guide anytime by logging in at </span><a class="link-build-content" style="color:inherit;text-decoration:none;" target="_blank" href="https://hey-germany.com"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px"><u>www.hey-germany.com</u></span></a><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px"> using the same email address.</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px">We're excited to support you on your journey!</span></p></div>
</td>
</tr>
<tr>
<td
align="left" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:0px;word-break:break-word;"
>
<div
style="font-family:Arial, sans-serif;font-size:13px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"
><p class="text-build-content" style="margin: 10px 0; margin-top: 10px;"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px">Warm regards,</span></p><p class="text-build-content" style="margin: 10px 0;"><span style="color:#5e6977;font-family:Arial;font-size:15px;line-height:22px">Your HeyGermany Team&nbsp;</span></p><p class="text-build-content" style="margin: 10px 0;"><span style="color:#5e6977;font-family:Arial;font-size:10px;line-height:22px">HeyGermany · Helping international nurses start their careers in Germany · www.hey-germany.com</span></p><p class="text-build-content" style="margin: 10px 0; margin-bottom: 10px;">&nbsp;</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
import { backendTranslationSnapshot, } from './backend-translation-snapshot.js';
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function parseLocaleData(value) {
if (typeof value === 'string') {
return parseLocaleData(JSON.parse(value));
}
if (isPlainObject(value)) {
return value;
}
return {};
}
function syncTranslationValue(existing, baseline) {
if (Array.isArray(baseline)) {
if (!Array.isArray(existing)) {
return baseline;
}
return baseline.map((item, index) => syncTranslationValue(existing[index], item));
}
if (isPlainObject(baseline)) {
const existingObject = isPlainObject(existing) ? existing : {};
return Object.fromEntries(Object.entries(baseline).map(([key, value]) => [
key,
syncTranslationValue(existingObject[key], value),
]));
}
if (existing === undefined || existing === null) {
return baseline;
}
return typeof existing === typeof baseline ? existing : baseline;
}
function contentJsonString(content) {
return JSON.stringify(content);
}
const mergeLocale = (...parts) => parts.reduce((acc, part) => ({ ...acc, ...part }), {});
export class TranslationService {
constructor(kysely) {
this.kysely = kysely;
this.locales = {};
this.loaded = false;
}
async ensureSnapshotSynced() {
const existingRows = await this.kysely
.selectFrom('backendTranslation')
.select(['locale', 'namespace', 'contentJson'])
.execute();
const existingMap = new Map(existingRows.map((row) => [`${row.locale}:${row.namespace}`, row]));
const snapshotMap = new Map(backendTranslationSnapshot.map((row) => [
`${row.locale}:${row.namespace}`,
row,
]));
for (const snapshotRow of backendTranslationSnapshot) {
const key = `${snapshotRow.locale}:${snapshotRow.namespace}`;
const existingRow = existingMap.get(key);
if (!existingRow) {
await this.kysely
.insertInto('backendTranslation')
.values({
locale: snapshotRow.locale,
namespace: snapshotRow.namespace,
contentJson: contentJsonString(snapshotRow.content),
})
.execute();
continue;
}
const currentContent = parseLocaleData(existingRow.contentJson);
const syncedContent = syncTranslationValue(currentContent, snapshotRow.content);
if (contentJsonString(currentContent) === contentJsonString(syncedContent)) {
continue;
}
await this.kysely
.updateTable('backendTranslation')
.set({
contentJson: contentJsonString(syncedContent),
updatedAt: new Date(),
})
.where('locale', '=', snapshotRow.locale)
.where('namespace', '=', snapshotRow.namespace)
.execute();
}
for (const existingRow of existingRows) {
const key = `${existingRow.locale}:${existingRow.namespace}`;
if (snapshotMap.has(key))
continue;
await this.kysely
.deleteFrom('backendTranslation')
.where('locale', '=', existingRow.locale)
.where('namespace', '=', existingRow.namespace)
.execute();
}
}
async init() {
if (this.loaded)
return;
await this.ensureSnapshotSynced();
const rows = await this.kysely
.selectFrom('backendTranslation')
.select(['locale', 'contentJson'])
.orderBy('locale')
.orderBy('namespace')
.execute();
const locales = {};
for (const row of rows) {
locales[row.locale] = mergeLocale(locales[row.locale] ?? {}, parseLocaleData(row.contentJson));
}
this.locales = locales;
this.loaded = true;
}
get(locale) {
if (!this.loaded) {
throw new Error('TranslationService not loaded. Call init() first.');
}
if (!this.locales[locale]) {
console.warn(`Missing locale: ${locale}`);
}
return this.locales[locale] || this.locales.en || {};
}
getAll() {
if (!this.loaded) {
throw new Error('TranslationService not loaded. Call init() first.');
}
return this.locales;
}
t(path, locale, variables = {}) {
const localeData = this.get(locale);
const keys = path.split('.');
let value = localeData;
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key];
}
else {
return `[Missing: ${path}]`;
}
}
if (typeof value !== 'string') {
return `[Invalid: ${path}]`;
}
return value.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (_, keyA, keyB) => {
const key = keyA ?? keyB;
return variables[key] || `{${key}}`;
});
}
tArray(path, locale = 'en') {
const localeData = this.get(locale);
const keys = path.split('.');
let value = localeData;
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key];
}
else {
return [`[Missing: ${path}]`];
}
}
if (!Array.isArray(value)) {
return [`[Invalid array: ${path}]`];
}
return value;
}
}
//# sourceMappingURL=translation-service.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,225 @@
import type { Kysely } from 'kysely'
import type { KyselyDB } from '../database-types.js'
import type { TranslationTokens } from '../engines/language.types.gen.js'
import {
backendTranslationSnapshot,
type BackendTranslationNamespace,
} from './backend-translation-snapshot.js'
type LocaleData = Record<string, any>
type Locales = Record<string, LocaleData>
type BackendTranslationRow = {
locale: string
namespace: BackendTranslationNamespace
contentJson: unknown
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function parseLocaleData(value: unknown): LocaleData {
if (typeof value === 'string') {
return parseLocaleData(JSON.parse(value))
}
if (isPlainObject(value)) {
return value as LocaleData
}
return {}
}
function syncTranslationValue(existing: unknown, baseline: unknown): unknown {
if (Array.isArray(baseline)) {
if (!Array.isArray(existing)) {
return baseline
}
return baseline.map((item, index) => syncTranslationValue(existing[index], item))
}
if (isPlainObject(baseline)) {
const existingObject = isPlainObject(existing) ? existing : {}
return Object.fromEntries(
Object.entries(baseline).map(([key, value]) => [
key,
syncTranslationValue(existingObject[key], value),
])
)
}
if (existing === undefined || existing === null) {
return baseline
}
return typeof existing === typeof baseline ? existing : baseline
}
function contentJsonString(content: unknown): string {
return JSON.stringify(content)
}
const mergeLocale = (...parts: LocaleData[]): LocaleData =>
parts.reduce((acc, part) => ({ ...acc, ...part }), {})
export class TranslationService {
private locales: Locales = {}
private loaded = false
constructor(private readonly kysely: Kysely<KyselyDB>) {}
private async ensureSnapshotSynced(): Promise<void> {
const existingRows = await this.kysely
.selectFrom('backendTranslation')
.select(['locale', 'namespace', 'contentJson'])
.execute() as BackendTranslationRow[]
const existingMap = new Map<string, BackendTranslationRow>(
existingRows.map((row) => [`${row.locale}:${row.namespace}`, row] as const)
)
const snapshotMap = new Map<string, (typeof backendTranslationSnapshot)[number]>(
backendTranslationSnapshot.map((row) => [
`${row.locale}:${row.namespace}`,
row,
] as const)
)
for (const snapshotRow of backendTranslationSnapshot) {
const key = `${snapshotRow.locale}:${snapshotRow.namespace}`
const existingRow = existingMap.get(key)
if (!existingRow) {
await this.kysely
.insertInto('backendTranslation')
.values({
locale: snapshotRow.locale,
namespace: snapshotRow.namespace,
contentJson: contentJsonString(snapshotRow.content),
})
.execute()
continue
}
const currentContent = parseLocaleData(existingRow.contentJson)
const syncedContent = syncTranslationValue(currentContent, snapshotRow.content)
if (contentJsonString(currentContent) === contentJsonString(syncedContent)) {
continue
}
await this.kysely
.updateTable('backendTranslation')
.set({
contentJson: contentJsonString(syncedContent),
updatedAt: new Date(),
})
.where('locale', '=', snapshotRow.locale)
.where('namespace', '=', snapshotRow.namespace)
.execute()
}
for (const existingRow of existingRows) {
const key = `${existingRow.locale}:${existingRow.namespace}`
if (snapshotMap.has(key)) continue
await this.kysely
.deleteFrom('backendTranslation')
.where('locale', '=', existingRow.locale)
.where('namespace', '=', existingRow.namespace)
.execute()
}
}
async init(): Promise<void> {
if (this.loaded) return
await this.ensureSnapshotSynced()
const rows = await this.kysely
.selectFrom('backendTranslation')
.select(['locale', 'contentJson'])
.orderBy('locale')
.orderBy('namespace')
.execute() as Array<Pick<BackendTranslationRow, 'locale' | 'contentJson'>>
const locales: Locales = {}
for (const row of rows) {
locales[row.locale] = mergeLocale(
locales[row.locale] ?? {},
parseLocaleData(row.contentJson)
)
}
this.locales = locales
this.loaded = true
}
get(locale: string): LocaleData {
if (!this.loaded) {
throw new Error('TranslationService not loaded. Call init() first.')
}
if (!this.locales[locale]) {
console.warn(`Missing locale: ${locale}`)
}
return this.locales[locale] || this.locales.en || {}
}
getAll(): Locales {
if (!this.loaded) {
throw new Error('TranslationService not loaded. Call init() first.')
}
return this.locales
}
t(
path: TranslationTokens,
locale: string,
variables: Record<string, any> = {}
): string {
const localeData = this.get(locale)
const keys = path.split('.')
let value: any = localeData
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key]
} else {
return `[Missing: ${path}]`
}
}
if (typeof value !== 'string') {
return `[Invalid: ${path}]`
}
return value.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (_, keyA, keyB) => {
const key = keyA ?? keyB
return variables[key] || `{${key}}`
})
}
tArray(path: string, locale: string = 'en'): string[] {
const localeData = this.get(locale)
const keys = path.split('.')
let value: any = localeData
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key]
} else {
return [`[Missing: ${path}]`]
}
}
if (!Array.isArray(value)) {
return [`[Invalid array: ${path}]`]
}
return value
}
}

View File

@@ -0,0 +1,164 @@
import { betterAuth } from 'better-auth';
import { magicLink } from 'better-auth/plugins';
import { pikkuBetterAuth } from '#pikku/pikku-types.gen.js';
import { UserRoles, UserTypes } from '@heygermany/sdk';
const DEV_BETTER_AUTH_SECRET = 'dev-better-auth-secret-change-me';
const isLocalDomain = (domain) => {
return !domain || domain.includes('localhost') || domain.includes('127.0.0.1');
};
const getOrigin = (domain) => {
if (isLocalDomain(domain)) {
return 'http://localhost:3000';
}
return `https://api.${domain}`;
};
export const auth = pikkuBetterAuth(async ({ kysely, secrets, variables, config, emailService }) => {
const betterAuthSecret = (await variables.get('BETTER_AUTH_SECRET')) ||
await secrets.getSecret('BETTER_AUTH_SECRET').catch(() => DEV_BETTER_AUTH_SECRET);
if (process.env.NODE_ENV === 'production' && betterAuthSecret === DEV_BETTER_AUTH_SECRET) {
throw new Error('BETTER_AUTH_SECRET must be configured in production');
}
return betterAuth({
secret: betterAuthSecret,
baseURL: getOrigin(config.domain),
trustedOrigins: [
'http://localhost:3001',
'http://localhost:3000',
'http://127.0.0.1:3001',
'http://127.0.0.1:3000',
`https://${config.domain}`,
`https://api.${config.domain}`,
'https://*.pikkufabric.dev',
],
advanced: {
database: {
generateId: 'uuid',
},
crossSubDomainCookies: {
enabled: !isLocalDomain(config.domain),
domain: isLocalDomain(config.domain) ? undefined : config.domain,
},
},
database: {
db: kysely,
type: 'sqlite',
},
user: {
modelName: 'authUser',
fields: {
emailVerified: 'emailVerified',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
},
additionalFields: {
role: {
type: 'string',
required: true,
defaultValue: UserRoles.OWNER,
input: true,
},
type: {
type: 'string',
required: true,
defaultValue: UserTypes.CANDIDATE,
input: true,
},
},
},
session: {
modelName: 'authSession',
fields: {
expiresAt: 'expiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
ipAddress: 'ipAddress',
userAgent: 'userAgent',
userId: 'userId',
},
},
account: {
modelName: 'authAccount',
fields: {
accountId: 'accountId',
providerId: 'providerId',
userId: 'userId',
accessToken: 'accessToken',
refreshToken: 'refreshToken',
idToken: 'idToken',
accessTokenExpiresAt: 'accessTokenExpiresAt',
refreshTokenExpiresAt: 'refreshTokenExpiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
},
},
verification: {
modelName: 'authVerification',
fields: {
expiresAt: 'expiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
},
},
emailAndPassword: {
enabled: true,
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
await emailService?.send({
to: user.email,
template: {
name: 'candidate-double-opt-in',
locale: 'en',
data: {
firstname: user.name,
verificationLink: url,
},
},
});
},
},
plugins: [
magicLink({
disableSignUp: true,
sendMagicLink: async ({ email: to, token, metadata }) => {
const user = await kysely
.selectFrom('authUser')
.select(['id', 'name'])
.where('email', '=', to)
.executeTakeFirst();
const name = user?.name || to.split('@')[0];
const templateName = getMagicLinkTemplateName(metadata?.template);
const magicLink = buildMagicLink(config.domain, token);
await emailService?.send({
to,
template: {
name: templateName,
locale: 'en',
data: {
firstname: name,
magicLink,
},
},
});
},
}),
],
});
});
const buildMagicLink = (domain, token) => {
const host = isLocalDomain(domain) ? `http://${domain}` : `https://${domain}`;
return `${host}/auth/magic-link?token=${token}&redirect=/jobs/application`;
};
const MAGIC_LINK_TEMPLATE_NAMES = new Set([
'candidate-magic-link',
'candidate-qualified',
'candidate-invalid-documents',
'candidate-process-incomplete',
]);
const getMagicLinkTemplateName = (template) => {
if (typeof template === 'string' && MAGIC_LINK_TEMPLATE_NAMES.has(template)) {
return template;
}
return 'candidate-magic-link';
};
//# sourceMappingURL=auth.wiring.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"auth.wiring.js","sourceRoot":"","sources":["auth.wiring.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,202 @@
import { betterAuth } from 'better-auth'
import { admin, magicLink } from 'better-auth/plugins'
import { actor, fabric } from '@pikku/better-auth'
import { pikkuBetterAuth } from '#pikku/pikku-types.gen.js'
import { UserRoles, UserTypes } from '@heygermany/sdk'
import type { EmailTemplateName } from '../../.pikku/email/pikku-emails.gen.js'
const DEV_BETTER_AUTH_SECRET = 'dev-better-auth-secret-change-me'
const isLocalDomain = (domain: string) => {
return !domain || domain.includes('localhost') || domain.includes('127.0.0.1')
}
const getOrigin = (domain: string) => {
if (isLocalDomain(domain)) {
return 'http://localhost:3000'
}
return `https://api.${domain}`
}
export const auth = pikkuBetterAuth(async ({ kysely, secrets, variables, config, emailService }) => {
const betterAuthSecret =
(await variables.get('BETTER_AUTH_SECRET')) ||
await secrets.getSecret('BETTER_AUTH_SECRET').catch(() => DEV_BETTER_AUTH_SECRET)
if (process.env.NODE_ENV === 'production' && betterAuthSecret === DEV_BETTER_AUTH_SECRET) {
throw new Error('BETTER_AUTH_SECRET must be configured in production')
}
// Genuinely optional: unset simply disables /api/auth/sign-in/actor (scenarios
// off for this deployment) — the actor plugin refuses all sign-ins without it.
const SCENARIO_ACTOR_SECRET = await secrets
.getSecret('SCENARIO_ACTOR_SECRET')
.catch(() => undefined)
// Fabric operator admin: the RSA public key the control plane's token is
// verified against. The Fabric deployer pushes FABRIC_AUTH_PUBLIC_KEY onto
// every stage; locally it's simply absent, which disables /sign-in/fabric.
const FABRIC_AUTH_PUBLIC_KEY = await variables.get('FABRIC_AUTH_PUBLIC_KEY')
return betterAuth({
secret: betterAuthSecret,
baseURL: getOrigin(config.domain),
trustedOrigins: [
'http://localhost:3001',
'http://localhost:3000',
'http://127.0.0.1:3001',
'http://127.0.0.1:3000',
`https://${config.domain}`,
`https://api.${config.domain}`,
// Fabric deploys every runtime to a *.pikkufabric.dev subdomain; the
// request Origin is that dynamic host, so trust it or auth 403s
// INVALID_ORIGIN (baseURL is pinned to the real api.<domain>).
'https://*.pikkufabric.dev',
],
advanced: {
database: {
generateId: 'uuid',
},
crossSubDomainCookies: {
enabled: !isLocalDomain(config.domain),
domain: isLocalDomain(config.domain) ? undefined : config.domain,
},
},
database: {
db: kysely as any,
type: 'sqlite',
},
user: {
modelName: 'authUser',
fields: {
emailVerified: 'emailVerified',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
},
additionalFields: {
role: {
type: 'string',
required: true,
defaultValue: UserRoles.OWNER,
input: true,
},
type: {
type: 'string',
required: true,
defaultValue: UserTypes.CANDIDATE,
input: true,
},
},
},
session: {
modelName: 'authSession',
// Signed session cookie cache: lets the global middleware resolve the
// session statelessly (betterAuthStatelessSession) without services.auth()
// — which is tree-shaken off the RPC bundle on the deployed CF Worker, so
// the full-server betterAuthSession 500s there.
cookieCache: { enabled: true, maxAge: 5 * 60 },
fields: {
expiresAt: 'expiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
ipAddress: 'ipAddress',
userAgent: 'userAgent',
userId: 'userId',
},
},
account: {
modelName: 'authAccount',
fields: {
accountId: 'accountId',
providerId: 'providerId',
userId: 'userId',
accessToken: 'accessToken',
refreshToken: 'refreshToken',
idToken: 'idToken',
accessTokenExpiresAt: 'accessTokenExpiresAt',
refreshTokenExpiresAt: 'refreshTokenExpiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
},
},
verification: {
modelName: 'authVerification',
fields: {
expiresAt: 'expiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
},
},
emailAndPassword: {
enabled: true,
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
await emailService?.send({
to: user.email,
template: {
name: 'candidate-double-opt-in',
locale: 'en',
data: {
firstname: user.name,
verificationLink: url,
},
},
})
},
},
// admin(): list/setRole/ban/impersonate users (db/sqlite/0005-admin.sql).
// actor(): synthetic scenario users (db/sqlite/0004-user-actor.sql).
// fabric(): Fabric console operator sign-in (db/sqlite/0006-fabric.sql).
plugins: [
actor({ secret: SCENARIO_ACTOR_SECRET }),
admin(),
fabric({ publicKey: FABRIC_AUTH_PUBLIC_KEY }),
magicLink({
disableSignUp: true,
sendMagicLink: async ({ email: to, token, metadata }) => {
const user = await kysely
.selectFrom('authUser')
.select(['id', 'name'])
.where('email', '=', to)
.executeTakeFirst()
const name = user?.name || to.split('@')[0]
const templateName = getMagicLinkTemplateName(metadata?.template)
const magicLink = buildMagicLink(config.domain, token)
await emailService?.send({
to,
template: {
name: templateName,
locale: 'en',
data: {
firstname: name,
magicLink,
},
},
})
},
}),
],
})
})
const buildMagicLink = (domain: string, token: string) => {
const host = isLocalDomain(domain) ? `http://${domain}` : `https://${domain}`
return `${host}/auth/magic-link?token=${token}&redirect=/jobs/application`
}
const MAGIC_LINK_TEMPLATE_NAMES = new Set<EmailTemplateName>([
'candidate-magic-link',
'candidate-qualified',
'candidate-invalid-documents',
'candidate-process-incomplete',
])
const getMagicLinkTemplateName = (template: unknown): EmailTemplateName => {
if (typeof template === 'string' && MAGIC_LINK_TEMPLATE_NAMES.has(template as EmailTemplateName)) {
return template as EmailTemplateName
}
return 'candidate-magic-link'
}

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "../..",
"types": ["node"],
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"references": [
{"path": "../sdk"}
],
"include": [".pikku/**/*.ts", "src/*.ts", "src/**/*.ts", "../../src/scaffold/**/*.ts"]
}

3
packages/mantine-theme/index.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
import type { MantineThemeOverride } from '@pikku/mantine/core';
export declare const DAYJS_LOCALE_MAP: Record<string, string>;
export declare const theme: MantineThemeOverride;

View File

@@ -0,0 +1,159 @@
import { Anchor, Button, Container, createTheme, InputWrapper, List, rem, Stepper } from '@mantine/core';
import { generateColors } from '@mantine/colors-generator';
export const DAYJS_LOCALE_MAP = {
en: 'en',
de: 'de',
uk: 'uk',
fil: 'en',
tr: 'tr',
bs: 'bs',
sq: 'sq',
vi: 'vi',
es: 'es',
hi: 'hi',
ar: 'ar',
};
export const theme = createTheme({
primaryColor: 'primary',
breakpoints: {
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
},
colors: {
primary: generateColors('#eab308'),
education: generateColors('#34D399'),
license: generateColors('#FBBF24'),
language: generateColors('#5FA5FA'),
additional: generateColors('#C084FC'),
success: generateColors('#10B981'),
error: generateColors('#EF4444'),
},
fontFamily: 'ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"',
fontFamilyMonospace: 'ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace',
fontSizes: {
xs: rem(10),
sm: rem(12),
md: rem(14),
lg: rem(16),
lgxl: rem(18),
xl: rem(20),
xxl: rem(32),
xxll: rem(38),
xxxl: rem(48),
xxxxl: rem(56),
hero: rem(64),
},
headings: {
fontFamily: 'ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"',
sizes: {
h1: {
fontSize: rem(40),
lineHeight: '1.2',
fontWeight: '400',
},
h2: {
fontSize: rem(20),
lineHeight: '1.2',
fontWeight: '600',
},
h3: {
fontSize: rem(18),
lineHeight: '1.2',
fontWeight: '600',
},
h4: {
fontSize: rem(16),
lineHeight: '1.2',
fontWeight: '600',
},
h5: {
fontSize: rem(14),
lineHeight: '1.2',
fontWeight: '600',
},
h6: {
fontSize: rem(12),
lineHeight: '1.2',
fontWeight: '600',
},
},
},
components: {
Container: Container.extend({
defaultProps: {
px: 'xl',
},
vars: (currentTheme, props) => {
if (props.size === 'xl') {
return {
root: {
'--container-size': '80rem',
},
};
}
return { root: {} };
},
}),
Button: Button.extend({
defaultProps: {
size: 'md',
radius: 'lg',
},
styles: () => ({
root: {
color: 'var(--mantine-color-black)',
},
}),
}),
InputWrapper: InputWrapper.extend({
styles: {
label: {
fontSize: rem(14),
fontWeight: 500,
marginBottom: rem(10),
},
},
}),
Stepper: Stepper.extend({
styles: {
stepIcon: {
backgroundColor: 'initial',
color: 'var(--mantine-color-dimmed)',
width: rem(30),
height: rem(30),
minWidth: rem(30),
minHeight: rem(30),
},
stepLabel: {
fontSize: rem(14),
color: 'var(--mantine-color-dimmed)',
},
},
}),
Anchor: Anchor.extend({
styles: (currentTheme) => ({
root: {
textDecoration: 'none',
color: currentTheme.colors.gray[9],
'&:hover, &:focusVisible': {
color: `${currentTheme.colors.blue[1]} !important`,
},
},
}),
}),
List: List.extend({
styles: (currentTheme) => ({
root: {
paddingLeft: rem(10),
},
item: {
color: currentTheme.colors.gray[8],
},
}),
}),
},
});
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,165 @@
import { Anchor, Button, Container, createTheme, InputWrapper, List, rem, Stepper } from '@pikku/mantine/core'
import type { MantineThemeOverride } from '@pikku/mantine/core'
import { generateColors } from '@mantine/colors-generator'
export const DAYJS_LOCALE_MAP: Record<string, string> = {
en: 'en',
de: 'de',
uk: 'uk',
fil: 'en',
tr: 'tr',
bs: 'bs',
sq: 'sq',
vi: 'vi',
es: 'es',
hi: 'hi',
ar: 'ar',
}
export const theme: MantineThemeOverride = createTheme({
primaryColor: 'primary',
breakpoints: {
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
},
colors: {
primary: generateColors('#eab308'),
education: generateColors('#34D399'),
license: generateColors('#FBBF24'),
language: generateColors('#5FA5FA'),
additional: generateColors('#C084FC'),
success: generateColors('#10B981'),
error: generateColors('#EF4444'),
},
fontFamily:
'ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"',
fontFamilyMonospace:
'ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace',
fontSizes: {
xs: rem(10),
sm: rem(12),
md: rem(14),
lg: rem(16),
lgxl: rem(18),
xl: rem(20),
xxl: rem(32),
xxll: rem(38),
xxxl: rem(48),
xxxxl: rem(56),
hero: rem(64),
},
headings: {
fontFamily:
'ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"',
sizes: {
h1: {
fontSize: rem(40),
lineHeight: '1.2',
fontWeight: '400',
},
h2: {
fontSize: rem(20),
lineHeight: '1.2',
fontWeight: '600',
},
h3: {
fontSize: rem(18),
lineHeight: '1.2',
fontWeight: '600',
},
h4: {
fontSize: rem(16),
lineHeight: '1.2',
fontWeight: '600',
},
h5: {
fontSize: rem(14),
lineHeight: '1.2',
fontWeight: '600',
},
h6: {
fontSize: rem(12),
lineHeight: '1.2',
fontWeight: '600',
},
},
},
components: {
Container: Container.extend({
defaultProps: {
px: 'xl',
},
vars: (currentTheme, props) => {
if (props.size === 'xl') {
return {
root: {
'--container-size': '80rem',
},
}
}
return { root: {} }
},
}),
Button: Button.extend({
defaultProps: {
size: 'md',
radius: 'lg',
},
styles: () => ({
root: {
color: 'var(--mantine-color-black)',
},
}),
}),
InputWrapper: InputWrapper.extend({
styles: {
label: {
fontSize: rem(14),
fontWeight: 500,
marginBottom: rem(10),
},
},
}),
Stepper: Stepper.extend({
styles: {
stepIcon: {
backgroundColor: 'initial',
color: 'var(--mantine-color-dimmed)',
width: rem(30),
height: rem(30),
minWidth: rem(30),
minHeight: rem(30),
},
stepLabel: {
fontSize: rem(14),
color: 'var(--mantine-color-dimmed)',
},
},
}),
Anchor: Anchor.extend({
styles: (currentTheme) => ({
root: {
textDecoration: 'none',
color: currentTheme.colors.gray[9],
'&:hover, &:focusVisible': {
color: `${currentTheme.colors.blue[1]} !important`,
},
},
}),
}),
List: List.extend({
styles: (currentTheme) => ({
root: {
paddingLeft: rem(10),
},
item: {
color: currentTheme.colors.gray[8],
},
}),
}),
},
})

View File

@@ -0,0 +1,30 @@
{
"name": "@heygermany/mantine-theme",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "index.ts",
"scripts": {
"tsc": "tsc"
},
"dependencies": {
"@mantine/colors-generator": "^8.2.8",
"@pikku/mantine": "^0.12.6",
"@pikku/react": "^0.12.5",
"chroma-js": "^3.1.2"
},
"peerDependencies": {
"@mantine/core": "^8.2.8",
"@mantine/hooks": "^8.2.8",
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@mantine/core": "^8.2.8",
"@mantine/hooks": "^8.2.8",
"@types/node": "^22",
"react": "^19",
"react-dom": "^19",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node"],
"composite": true,
"declaration": true,
"noEmit": false,
"skipLibCheck": true,
"rootDir": ".",
"outDir": "dist"
},
"files": [],
"include": ["**/*.ts", "**/*.d.ts"],
"exclude": ["dist/**/*", "lib/**/*"]
}

112
packages/sdk/config.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Application configuration
*/
/**
* Countries that are currently supported for document analysis
* Uses ISO 3166-1 alpha-3 country codes
*/
export const SUPPORTED_COUNTRIES = [
'DEU', // Germany
'UKR', // Ukraine
'PHL', // Philippines
'TUR', // Turkey
'BIH', // Bosnia & Herzegovina
'ALB', // Albania
'VNM', // Vietnam
'MEX', // Mexico
'IND', // India
'TUN', // Tunisia
'SYR', // Syria
] as const
export const SUPPORTED_COUNTRY_NAMES: Record<(typeof SUPPORTED_COUNTRIES)[number], string> = {
DEU: 'Germany',
UKR: 'Ukraine',
PHL: 'Philippines',
TUR: 'Turkey',
BIH: 'Bosnia & Herzegovina',
ALB: 'Albania',
VNM: 'Vietnam',
MEX: 'Mexico',
IND: 'India',
TUN: 'Tunisia',
SYR: 'Syria',
}
/**
* Supported application locales
* Maps locale codes to their display information
*/
export const SUPPORTED_LOCALES = {
en: {
code: 'en',
name: 'English',
flag: 'https://flagcdn.com/us.svg',
},
de: {
code: 'de',
name: 'Deutsch',
flag: 'https://flagcdn.com/de.svg',
},
uk: {
code: 'uk',
name: 'Українська',
flag: 'https://flagcdn.com/ua.svg',
},
fil: {
code: 'fil',
name: 'Filipino',
flag: 'https://flagcdn.com/ph.svg',
},
tr: {
code: 'tr',
name: 'Türkçe',
flag: 'https://flagcdn.com/tr.svg',
},
bs: {
code: 'bs',
name: 'Bosanski',
flag: 'https://flagcdn.com/ba.svg',
},
sq: {
code: 'sq',
name: 'Shqip',
flag: 'https://flagcdn.com/al.svg',
},
vi: {
code: 'vi',
name: 'Tiếng Việt',
flag: 'https://flagcdn.com/vn.svg',
},
es: {
code: 'es',
name: 'Español',
flag: 'https://flagcdn.com/mx.svg',
},
hi: {
code: 'hi',
name: 'हिन्दी',
flag: 'https://flagcdn.com/in.svg',
},
ar: {
code: 'ar',
name: 'العربية',
flag: 'https://flagcdn.com/tn.svg',
},
} as const
export const LOCALES = Object.keys(SUPPORTED_LOCALES) as SupportedLocale[]
export const DEFAULT_LOCALE: SupportedLocale = 'en'
/**
* Languages array for language selector components
*/
export const LANGUAGES = Object.values(SUPPORTED_LOCALES).map(locale => ({
label: locale.name,
value: locale.code,
flag: locale.flag,
}))
export type SupportedLocale = keyof typeof SUPPORTED_LOCALES
export type SupportedCountry = typeof SUPPORTED_COUNTRIES[number]

322
packages/sdk/db.ts Normal file
View File

@@ -0,0 +1,322 @@
export type UserRole = 'admin' | 'owner'
export type UserType = 'company' | 'backoffice' | 'candidate'
export type LanguageLevel =
| 'NONE'
| 'A_ONE'
| 'A_TWO'
| 'B_ONE'
| 'B_TWO'
| 'C_ONE'
| 'C_TWO'
export type CandidateDocumentType =
| 'education'
| 'license'
| 'language'
| 'work_experience'
| 'profile_image'
export type DocumentStatus = 'Missing' | 'Invalid' | 'Verified' | 'Unverified'
export type ApplicantStatus =
| 'Initial'
| 'Invalid'
| 'Pending'
| 'Complete'
| 'Failed'
export type NurseRecognitionGermany =
| 'nurse'
| 'assistant_nurse'
| 'nursing_helper'
export type NurseRecognitionHomeCountry =
| 'nurse'
| 'assistant_nurse'
| 'nursing_helper'
| 'none'
export type RecognitionLikelihood = 'low' | 'medium' | 'high'
export type ProfessionalRecognitionStatus =
| 'NOT_STARTED'
| 'IN_PROGRESS'
| 'COMPLETED'
export type LanguageCode =
| 'en'
| 'ar'
| 'bs'
| 'fil'
| 'hi'
| 'es'
| 'tr'
| 'uk'
| 'vi'
export type GermanState =
| 'Baden-Württemberg'
| 'Bayern'
| 'Berlin'
| 'Brandenburg'
| 'Bremen'
| 'Hamburg'
| 'Hessen'
| 'Mecklenburg-Vorpommern'
| 'Niedersachsen'
| 'Nordrhein-Westfalen'
| 'Rheinland-Pfalz'
| 'Saarland'
| 'Sachsen'
| 'Sachsen-Anhalt'
| 'Schleswig-Holstein'
| 'Thüringen'
export type RecognitionDossierStatus =
| 'draft'
| 'blocked'
| 'ready'
| 'generating'
| 'generated'
| 'unsupported'
| 'failed'
export type RecognitionDossierDocumentCategory =
| 'passport'
| 'birth_certificate'
| 'name_change_proof'
| 'employer_intent'
| 'medical_certificate'
| 'police_clearance'
| 'translation'
| 'authority_form'
| 'power_of_attorney'
| 'waiver_form'
| 'other'
export interface Candidate {
candidateId: string
email: string
name: string
stateWorkPreference: GermanState[] | null
joinTalentPool: boolean | null
livingInGermany: boolean | null
euPassport: boolean | null
countryEducation: string | null
licenseProvided: boolean | null
languageCertificateProvided: boolean | null
languageSelfAssessmentLevel: LanguageLevel | null
submittedAt: Date | null
createdAt: Date | null
lastUpdatedAt: Date | null
phone: string | null
dateOfBirth: Date | null
currentResidence: string | null
qualificationStatusHomeCountry: NurseRecognitionHomeCountry | null
qualificationStatusGermany: NurseRecognitionGermany | null
licenseMandatory: boolean | null
durationRequirementsMet: boolean | null
status: ApplicantStatus
incompleteReminderSentAt: Date | null
recognitionLikelihood: RecognitionLikelihood | null
professionalRecognitionStatus: ProfessionalRecognitionStatus | null
nationality: string | null
languagesSpoken: LanguageCode[] | null
statusChangedAt: Date
lifecycleNotifications: unknown
analysisLastRunAt: Date | null
analysisInputFingerprint: string | null
invalidatedAt: Date | null
reuploadRequestedAt: Date | null
reuploadedAt: Date | null
failedPurgedAt: Date | null
}
export interface CandidateDocument {
documentId: string
candidateId: string
category: CandidateDocumentType
title: string | null
note: string | null
filePath: string
fileName: string
fileSize: number | null
mimeType: string | null
uploaded: boolean | null
createdAt: Date | null
lastUpdatedAt: Date | null
ocrText: string | null
ocrModel:
| 'tesseract'
| 'azure_cognitive'
| 'aws_textract'
| 'google_vision'
| 'manual_entry'
| null
ocrStartedAt: Date | null
ocrEndedAt: Date | null
documentStatus: DocumentStatus
}
export interface CandidateEducation {
educationId: string
candidateId: string
institution: string | null
country: string | null
program: string | null
completionDate: Date | null
aiInstitution: string | null
aiCountry: string | null
aiProgram: string | null
aiCompletionDate: Date | null
aiExtractedBy:
| 'gpt-five'
| 'claude-three-opus'
| 'claude-three-sonnet'
| 'claude-three-haiku'
| 'gemini-pro'
| null
aiExtractionDate: Date | null
createdAt: Date | null
lastUpdatedAt: Date | null
nursingRelatedDegree: boolean | null
accreditedUniversity: boolean | null
accreditedStudyProgram: boolean | null
aiNursingRelatedDegree: boolean | null
aiAccreditedUniversity: boolean | null
aiAccreditedStudyProgram: boolean | null
hasTheory: boolean | null
hasPractice: boolean | null
durationHours: number | null
isNursingFocus: boolean | null
aiHasTheory: boolean | null
aiHasPractice: boolean | null
aiDurationHours: number | null
aiIsNursingFocus: boolean | null
}
export interface CandidateExperience {
experienceId: string
candidateId: string
employerName: string | null
roleTitle: string | null
department: string | null
startDate: Date | null
endDate: Date | null
createdAt: Date | null
lastUpdatedAt: Date | null
country: string | null
city: string | null
facilityType: string | null
}
export interface CandidateLanguage {
languageId: string
candidateId: string
level: LanguageLevel | null
issuingBody: string | null
issueDate: Date | null
certifiedSkills: string | null
aiLevel: LanguageLevel | null
aiIssuingBody: string | null
aiIssueDate: Date | null
aiCertifiedSkills: string | null
aiExtractedBy:
| 'gpt-five'
| 'claude-three-opus'
| 'claude-three-sonnet'
| 'claude-three-haiku'
| 'gemini-pro'
| null
aiExtractionDate: Date | null
createdAt: Date | null
lastUpdatedAt: Date | null
}
export interface CandidateLicense {
licenseId: string
candidateId: string
licenseNumber: string | null
issuingAuthority: string | null
issueDate: Date | null
expiryDate: Date | null
status: string | null
licenseType: string | null
aiLicenseNumber: string | null
aiIssuingAuthority: string | null
aiIssueDate: Date | null
aiExpiryDate: Date | null
aiStatus: string | null
aiLicenseType: string | null
aiExtractedBy:
| 'gpt-five'
| 'claude-three-opus'
| 'claude-three-sonnet'
| 'claude-three-haiku'
| 'gemini-pro'
| null
aiExtractionDate: Date | null
createdAt: Date | null
lastUpdatedAt: Date | null
}
export interface RecognitionDossier {
dossierId: string
candidateId: string
targetState: GermanState
targetProfession: NurseRecognitionGermany
status: RecognitionDossierStatus
rulesVersion: string | null
inputFingerprint: string | null
generatedAt: Date | null
packageFilePath: string | null
submissionGuideJson: unknown
checklistJson: unknown
blockingIssuesJson: unknown
createdAt: Date | null
lastUpdatedAt: Date | null
}
export interface RecognitionDossierDocument {
documentId: string
dossierId: string
candidateId: string
category: RecognitionDossierDocumentCategory
title: string | null
note: string | null
filePath: string
fileName: string
fileSize: number | null
mimeType: string | null
uploaded: boolean | null
documentStatus: DocumentStatus
createdAt: Date | null
lastUpdatedAt: Date | null
}
export declare namespace DB {
export type Candidate = import('./db.js').Candidate
export type CandidateDocument = import('./db.js').CandidateDocument
export type CandidateEducation = import('./db.js').CandidateEducation
export type CandidateExperience = import('./db.js').CandidateExperience
export type CandidateLanguage = import('./db.js').CandidateLanguage
export type CandidateLicense = import('./db.js').CandidateLicense
export type RecognitionDossier = import('./db.js').RecognitionDossier
export type RecognitionDossierDocument = import('./db.js').RecognitionDossierDocument
export type UserRole = import('./db.js').UserRole
export type UserType = import('./db.js').UserType
export type LanguageLevel = import('./db.js').LanguageLevel
export type CandidateDocumentType = import('./db.js').CandidateDocumentType
export type DocumentStatus = import('./db.js').DocumentStatus
export type ApplicantStatus = import('./db.js').ApplicantStatus
export type NurseRecognitionGermany = import('./db.js').NurseRecognitionGermany
export type NurseRecognitionHomeCountry = import('./db.js').NurseRecognitionHomeCountry
export type RecognitionLikelihood = import('./db.js').RecognitionLikelihood
export type ProfessionalRecognitionStatus = import('./db.js').ProfessionalRecognitionStatus
export type LanguageCode = import('./db.js').LanguageCode
export type GermanState = import('./db.js').GermanState
export type RecognitionDossierStatus = import('./db.js').RecognitionDossierStatus
export type RecognitionDossierDocumentCategory = import('./db.js').RecognitionDossierDocumentCategory
}

149
packages/sdk/domain.ts Normal file
View File

@@ -0,0 +1,149 @@
import type {
ApplicantStatus,
CandidateDocumentType,
DocumentStatus,
GermanState,
LanguageCode,
LanguageLevel,
NurseRecognitionGermany,
NurseRecognitionHomeCountry,
ProfessionalRecognitionStatus,
RecognitionDossierDocumentCategory,
RecognitionDossierStatus,
RecognitionLikelihood,
UserRole,
UserType,
} from './db.js'
export const UserRoles = {
ADMIN: 'admin',
OWNER: 'owner',
} as const satisfies Record<string, UserRole>
export const UserTypes = {
COMPANY: 'company',
BACKOFFICE: 'backoffice',
CANDIDATE: 'candidate',
} as const satisfies Record<string, UserType>
export const LanguageLevels = {
NONE: 'NONE',
A_ONE: 'A_ONE',
A_TWO: 'A_TWO',
B_ONE: 'B_ONE',
B_TWO: 'B_TWO',
C_ONE: 'C_ONE',
C_TWO: 'C_TWO',
} as const satisfies Record<string, LanguageLevel>
export const CandidateDocumentTypes = {
EDUCATION: 'education',
LICENSE: 'license',
LANGUAGE: 'language',
WORK_EXPERIENCE: 'work_experience',
PROFILE_IMAGE: 'profile_image',
} as const satisfies Record<string, CandidateDocumentType>
export const DocumentStatuses = {
MISSING: 'Missing',
INVALID: 'Invalid',
VERIFIED: 'Verified',
UNVERIFIED: 'Unverified',
} as const satisfies Record<string, DocumentStatus>
export const ApplicantStatuses = {
INITIAL: 'Initial',
INVALID: 'Invalid',
PENDING: 'Pending',
COMPLETE: 'Complete',
FAILED: 'Failed',
} as const satisfies Record<string, ApplicantStatus>
export const NurseRecognitionGermanyValues = {
NURSE: 'nurse',
ASSISTANT_NURSE: 'assistant_nurse',
NURSING_HELPER: 'nursing_helper',
} as const satisfies Record<string, NurseRecognitionGermany>
export const NurseRecognitionHomeCountryValues = {
NURSE: 'nurse',
ASSISTANT_NURSE: 'assistant_nurse',
NURSING_HELPER: 'nursing_helper',
NONE: 'none',
} as const satisfies Record<string, NurseRecognitionHomeCountry>
export const RecognitionLikelihoodValues = {
LOW: 'low',
MEDIUM: 'medium',
HIGH: 'high',
} as const satisfies Record<string, RecognitionLikelihood>
export const ProfessionalRecognitionStatuses = {
NOT_STARTED: 'NOT_STARTED',
IN_PROGRESS: 'IN_PROGRESS',
COMPLETED: 'COMPLETED',
} as const satisfies Record<string, ProfessionalRecognitionStatus>
export const GermanStates: Record<GermanState, GermanState> = {
'Baden-Württemberg': 'Baden-Württemberg',
Bayern: 'Bayern',
Berlin: 'Berlin',
Brandenburg: 'Brandenburg',
Bremen: 'Bremen',
Hamburg: 'Hamburg',
Hessen: 'Hessen',
'Mecklenburg-Vorpommern': 'Mecklenburg-Vorpommern',
Niedersachsen: 'Niedersachsen',
'Nordrhein-Westfalen': 'Nordrhein-Westfalen',
'Rheinland-Pfalz': 'Rheinland-Pfalz',
Saarland: 'Saarland',
Sachsen: 'Sachsen',
'Sachsen-Anhalt': 'Sachsen-Anhalt',
'Schleswig-Holstein': 'Schleswig-Holstein',
Thüringen: 'Thüringen',
}
export const LanguageCodes: Record<Uppercase<LanguageCode>, LanguageCode> = {
EN: 'en',
AR: 'ar',
BS: 'bs',
FIL: 'fil',
HI: 'hi',
ES: 'es',
TR: 'tr',
UK: 'uk',
VI: 'vi',
}
export const RecognitionDossierStatuses = {
DRAFT: 'draft',
BLOCKED: 'blocked',
READY: 'ready',
GENERATING: 'generating',
GENERATED: 'generated',
UNSUPPORTED: 'unsupported',
FAILED: 'failed',
} as const satisfies Record<string, RecognitionDossierStatus>
export const RecognitionDossierDocumentCategories = {
PASSPORT: 'passport',
BIRTH_CERTIFICATE: 'birth_certificate',
NAME_CHANGE_PROOF: 'name_change_proof',
EMPLOYER_INTENT: 'employer_intent',
MEDICAL_CERTIFICATE: 'medical_certificate',
POLICE_CLEARANCE: 'police_clearance',
TRANSLATION: 'translation',
AUTHORITY_FORM: 'authority_form',
POWER_OF_ATTORNEY: 'power_of_attorney',
WAIVER_FORM: 'waiver_form',
OTHER: 'other',
} as const satisfies Record<string, RecognitionDossierDocumentCategory>
export const PilotGermanStates = [
GermanStates.Bayern,
GermanStates.Hessen,
GermanStates.Niedersachsen,
GermanStates['Baden-Württemberg'],
GermanStates['Nordrhein-Westfalen'],
GermanStates.Berlin,
] as const

4
packages/sdk/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './db.js'
export * from './domain.js'
export * from './types.js'
export * from './config.js'

27
packages/sdk/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@heygermany/sdk",
"version": "0.0.0",
"description": "The sdk methods shared by backend and frontend candidates",
"license": "UNLICENSED",
"type": "module",
"private": true,
"main": "index.ts",
"repository": {
"directory": "packages/api",
"type": "git",
"url": "ssh://git@github.com/pikkujs/yarn-workspace-starter.git"
},
"author": "yasser.fadl@gmail.com",
"scripts": {
"tsc": "tsc",
"ncu": "ncu"
},
"dependencies": {
"@pikku/fetch": "^0.12.6",
"@pikku/websocket": "^0.12.2"
},
"devDependencies": {
"@types/node": "^22",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,67 @@
/**
* This file was generated by @pikku/cli@0.12.76
*/
import { CorePikkuFetch, HTTPMethod } from '@pikku/fetch'
import type { HTTPWiringsMap, HTTPWiringHandlerOf, HTTPWiringsWithMethod } from '@heygermany/functions/.pikku/http/pikku-http-wirings-map.gen.d.js'
export class PikkuFetch extends CorePikkuFetch {
public async post<Route extends HTTPWiringsWithMethod<'POST'>>(
route: Route,
...args: null extends HTTPWiringHandlerOf<Route, 'POST'>['input']
? [data?: Exclude<HTTPWiringHandlerOf<Route, 'POST'>['input'], null>, options?: Omit<RequestInit, 'body'>]
: [data: HTTPWiringHandlerOf<Route, 'POST'>['input'], options?: Omit<RequestInit, 'body'>]
): Promise<HTTPWiringHandlerOf<Route, 'POST'>['output']> {
const [data, options] = args;
return super.api(route, 'POST', data, options);
}
public async get<Route extends HTTPWiringsWithMethod<'GET'>>(
route: Route,
...args: null extends HTTPWiringHandlerOf<Route, 'GET'>['input']
? [data?: Exclude<HTTPWiringHandlerOf<Route, 'GET'>['input'], null>, options?: Omit<RequestInit, 'body'>]
: [data: HTTPWiringHandlerOf<Route, 'GET'>['input'], options?: Omit<RequestInit, 'body'>]
): Promise<HTTPWiringHandlerOf<Route, 'GET'>['output']> {
const [data, options] = args;
return super.api(route, 'GET', data, options);
}
public async patch<Route extends HTTPWiringsWithMethod<'PATCH'>>(
route: Route,
...args: null extends HTTPWiringHandlerOf<Route, 'PATCH'>['input']
? [data?: Exclude<HTTPWiringHandlerOf<Route, 'PATCH'>['input'], null>, options?: Omit<RequestInit, 'body'>]
: [data: HTTPWiringHandlerOf<Route, 'PATCH'>['input'], options?: Omit<RequestInit, 'body'>]
): Promise<HTTPWiringHandlerOf<Route, 'PATCH'>['output']> {
const [data, options] = args;
return super.api(route, 'PATCH', data, options);
}
public async head<Route extends HTTPWiringsWithMethod<'HEAD'>>(
route: Route,
...args: null extends HTTPWiringHandlerOf<Route, 'HEAD'>['input']
? [data?: Exclude<HTTPWiringHandlerOf<Route, 'HEAD'>['input'], null>, options?: Omit<RequestInit, 'body'>]
: [data: HTTPWiringHandlerOf<Route, 'HEAD'>['input'], options?: Omit<RequestInit, 'body'>]
): Promise<HTTPWiringHandlerOf<Route, 'HEAD'>['output']> {
const [data, options] = args;
return super.api(route, 'HEAD', data, options);
}
public async delete<Route extends HTTPWiringsWithMethod<'DELETE'>>(
route: Route,
...args: null extends HTTPWiringHandlerOf<Route, 'DELETE'>['input']
? [data?: Exclude<HTTPWiringHandlerOf<Route, 'DELETE'>['input'], null>, options?: Omit<RequestInit, 'body'>]
: [data: HTTPWiringHandlerOf<Route, 'DELETE'>['input'], options?: Omit<RequestInit, 'body'>]
): Promise<HTTPWiringHandlerOf<Route, 'DELETE'>['output']> {
const [data, options] = args;
return super.api(route, 'DELETE', data, options);
}
public async fetch<
Route extends keyof HTTPWiringsMap,
Method extends keyof HTTPWiringsMap[Route]
>(route: Route, method: Method, data: HTTPWiringHandlerOf<Route, Method>['input'], options?: Omit<RequestInit, 'body'>): Promise<Response> {
return await super.fetch(route, method as HTTPMethod, data, options);
}
}
export const pikkuFetch = new PikkuFetch();

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node"],
"composite": true,
"declaration": true,
"noEmit": false,
"skipLibCheck": true,
"rootDir": ".",
"outDir": "dist"
},
"files": [],
"include": ["**/*.ts", "**/*.d.ts"],
"exclude": ["dist/**/*", "lib/**/*"]
}

408
packages/sdk/types.ts Normal file
View File

@@ -0,0 +1,408 @@
import type {
ApplicantStatus,
Candidate as DBCandidate,
CandidateDocument,
CandidateEducation,
CandidateExperience,
CandidateLanguage,
CandidateLicense,
DB,
DocumentStatus,
GermanState,
LanguageCode,
LanguageLevel,
NurseRecognitionGermany,
NurseRecognitionHomeCountry,
ProfessionalRecognitionStatus,
RecognitionDossierDocumentCategory,
RecognitionDossierStatus,
RecognitionLikelihood,
} from './db.js'
export type CountriesMap = Record<string, string>
export interface ApplicationCandidateInfo {
stateWorkPreference: GermanState[] | null
joinTalentPool: boolean | null
livingInGermany: boolean | null
euPassport: boolean | null
countryEducation: string | null
submittedAt: Date | null
languagesSpoken: LanguageCode[] | null
nationality: string | null
currentResidence: string | null
professionalRecognitionStatus: ProfessionalRecognitionStatus | null
qualificationStatusGermany: NurseRecognitionGermany | null
recognitionLikelihood: RecognitionLikelihood | null
}
export type ApplicationCandidateLanguage = Pick<
DBCandidate,
'languageCertificateProvided' | 'languageSelfAssessmentLevel'
> & {
languageCertificates: CandidateDocument[]
}
export interface ApplicationCandidateLicense {
licenses: CandidateDocument[]
licenseProvided: boolean | null
}
export interface ApplicationCandidateEducation {
education: CandidateDocument[]
}
export interface ApplicationCandidateWorkExperience {
workExperience: CandidateDocument[]
}
export type PublicCandidateExperience = {
employerName: string | null
roleTitle: string | null
department: string | null
country: string | null
city: string | null
facilityType: string | null
startDate: Date | null
endDate: Date | null
}
export interface PublicCandidateEducationSummary {
institution: string | null
program: string | null
country: string | null
completionDate: Date | null
nursingRelatedDegree: boolean | null
}
export interface PublicCandidateInfo {
candidateId: string
name: string
stateWorkPreference: GermanState[] | null
joinTalentPool: boolean | null
livingInGermany: boolean | null
euPassport: boolean | null
countryEducation: string | null
submittedAt: Date | null
languagesSpoken: LanguageCode[] | null
nationality: string | null
currentResidence: string | null
professionalRecognitionStatus: ProfessionalRecognitionStatus | null
qualificationStatusGermany: NurseRecognitionGermany | null
recognitionLikelihood: RecognitionLikelihood | null
languageSelfAssessmentLevel: LanguageLevel | null
languageCertificateProvided: boolean | null
qualificationStatusHomeCountry: NurseRecognitionHomeCountry | null
createdAt: Date | null
age: number | null
hasVerifiedLicense: boolean
profileImageUrl: string | null
totalExperienceMonths: number | null
experience: PublicCandidateExperience[]
education: PublicCandidateEducationSummary | null
}
export interface ApplicationCandidateData {
name: string
candidateId: string
dateOfBirth: Date | null
submittedAt: Date | null
status: ApplicantStatus
stateWorkPreference: GermanState[] | null
joinTalentPool: boolean | null
livingInGermany: boolean | null
euPassport: boolean | null
countryEducation: string | null
languagesSpoken: LanguageCode[] | null
nationality: string | null
currentResidence: string | null
professionalRecognitionStatus: ProfessionalRecognitionStatus | null
qualificationStatusGermany: NurseRecognitionGermany | null
recognitionLikelihood: RecognitionLikelihood | null
languageCertificateProvided: boolean | null
languageSelfAssessmentLevel: LanguageLevel | null
languageCertificates: CandidateDocument[]
education: CandidateDocument[]
licenses: CandidateDocument[]
workExperience: CandidateDocument[]
}
export type QualificationCardStatus =
| 'completed'
| 'required'
| 'in-progress'
| 'optional'
export type QualificationCard = {
title: string
description: string
status: QualificationCardStatus
progress?: number // 0-100 only when status === 'in-progress'
requirements: string[]
steps?: string[]
}
export type NextStepItem = {
title: string
subtitle: string
costs: string
button?: string
buttonimprove?: string
buttonjoin?: string
}
export type NextSteps = {
gethelp: NextStepItem
connect: NextStepItem
documents?: NextStepItem
submit?: NextStepItem
compensatory?: NextStepItem
german?: NextStepItem
}
export type CandidateResults = {
education: QualificationCard
license: QualificationCard
language: QualificationCard
experience: QualificationCard
copies: QualificationCard
originals: QualificationCard
joinTalentPool: boolean | null
nextSteps: NextSteps | null
textContent: {
renderPath: string | null
hero: string
result: string
explanation: string[]
recognition?: string[]
notes: {
afterExplanation: string[]
}
}
}
export interface CandidateLifecycleAnalysisNotification {
issues?: string[]
updated_at?: string
updatedAt?: string
[key: string]: unknown
}
export type CandidateLifecycleNotifications = Record<string, unknown> & {
analysis?: CandidateLifecycleAnalysisNotification
}
export interface BackOfficeDocuments {
analysis: {
education: CandidateEducation | null
license: CandidateLicense | null
language: CandidateLanguage | null
experience: CandidateExperience | null
}
documents: {
education: Array<CandidateDocument>
license: Array<CandidateDocument>
language: Array<CandidateDocument>
experience: Array<CandidateDocument>
}
}
export interface BackOfficeCandidateListItem {
candidateId: string
email: string
name: string
stateWorkPreference: GermanState[] | null
joinTalentPool: boolean | null
livingInGermany: boolean | null
euPassport: boolean | null
countryEducation: string | null
licenseProvided: boolean | null
languageCertificateProvided: boolean | null
languageSelfAssessmentLevel: LanguageLevel | null
submittedAt: Date | null
createdAt: Date | null
lastUpdatedAt: Date | null
phone: string | null
dateOfBirth: Date | null
currentResidence: string | null
qualificationStatusHomeCountry: NurseRecognitionHomeCountry | null
qualificationStatusGermany: NurseRecognitionGermany | null
licenseMandatory: boolean | null
durationRequirementsMet: boolean | null
status: ApplicantStatus
incompleteReminderSentAt: Date | null
recognitionLikelihood: RecognitionLikelihood | null
professionalRecognitionStatus: ProfessionalRecognitionStatus | null
nationality: string | null
languagesSpoken: LanguageCode[] | null
statusChangedAt: Date
analysisLastRunAt: Date | null
analysisInputFingerprint: string | null
invalidatedAt: Date | null
reuploadRequestedAt: Date | null
reuploadedAt: Date | null
failedPurgedAt: Date | null
}
export interface BackOfficeCandidate {
candidateId: string
email: string
name: string
stateWorkPreference: GermanState[] | null
joinTalentPool: boolean | null
livingInGermany: boolean | null
euPassport: boolean | null
countryEducation: string | null
licenseProvided: boolean | null
languageCertificateProvided: boolean | null
languageSelfAssessmentLevel: LanguageLevel | null
submittedAt: Date | null
createdAt: Date | null
lastUpdatedAt: Date | null
phone: string | null
dateOfBirth: Date | null
currentResidence: string | null
qualificationStatusHomeCountry: NurseRecognitionHomeCountry | null
qualificationStatusGermany: NurseRecognitionGermany | null
licenseMandatory: boolean | null
durationRequirementsMet: boolean | null
status: ApplicantStatus
incompleteReminderSentAt: Date | null
recognitionLikelihood: RecognitionLikelihood | null
professionalRecognitionStatus: ProfessionalRecognitionStatus | null
nationality: string | null
languagesSpoken: LanguageCode[] | null
statusChangedAt: Date
analysisLastRunAt: Date | null
analysisInputFingerprint: string | null
invalidatedAt: Date | null
reuploadRequestedAt: Date | null
reuploadedAt: Date | null
failedPurgedAt: Date | null
analysis: {
education: CandidateEducation | null
license: CandidateLicense | null
language: CandidateLanguage | null
experience: CandidateExperience | null
}
documents: {
education: Array<CandidateDocument>
license: Array<CandidateDocument>
language: Array<CandidateDocument>
experience: Array<CandidateDocument>
}
lifecycleNotifications?: CandidateLifecycleNotifications | null
}
export interface RecognitionDossierBlockingIssue {
code: string
message: string
}
export interface RecognitionDossierChecklistItem {
id: string
label: string
status: string
message: string | null
source: string | null
sourceLabel: string | null
required: boolean
notes: string[]
}
export interface RecognitionDossierSubmissionGuide {
authorityName: string | null
authorityWebsiteUrl: string | null
submissionMode: string | null
submissionUrl: string | null
postalAddress: string[]
contactEmail: string | null
contactPhone: string | null
notes: string[]
lastVerifiedAt: string | null
}
export interface RecognitionDossierDocumentSummary {
documentId: string
candidateId: string
dossierId: string
category: RecognitionDossierDocumentCategory
title: string | null
note: string | null
fileName: string
fileSize: number | null
mimeType: string | null
uploaded: boolean
documentStatus: DocumentStatus
createdAt: Date | null
lastUpdatedAt: Date | null
}
export interface RecognitionDossierSummary {
dossierId: string
candidateId: string
targetState: GermanState
targetProfession: NurseRecognitionGermany
status: RecognitionDossierStatus
rulesVersion: string | null
generatedAt: Date | null
lastUpdatedAt: Date | null
hasPackage: boolean
blockingIssues: RecognitionDossierBlockingIssue[]
}
export interface RecognitionDossierDetail extends RecognitionDossierSummary {
supported: boolean
packageFilePath: string | null
downloadUrl: string | null
submissionGuide: RecognitionDossierSubmissionGuide | null
checklist: RecognitionDossierChecklistItem[]
documents: RecognitionDossierDocumentSummary[]
}
export interface GetCandidateResult {
name: string
qualificationStatusGermany: NurseRecognitionGermany | null
qualificationStatusHomeCountry: NurseRecognitionHomeCountry | null
euPassport: boolean | null
livingInGermany: boolean | null
licenseMandatory: boolean | null
joinTalentPool: boolean | null
nursingRelatedDegree: boolean | null
accreditedUniversity: boolean | null
accreditedStudyProgram: boolean | null
docs: {
cv: DocumentStatus
language: DocumentStatus
license: DocumentStatus
}
structuredTraining: {
hasDuration: boolean | null
isNursingFocus: boolean | null
hasTheoryHours: boolean | null
hasPracticeHours: boolean | null
overallStatus: boolean | null
}
languageAssessmentType: 'certificate' | 'self_assessment' | null
isValid: boolean
languageLevel?: LanguageLevel | null
}
export interface GetBackOfficeCandidatesOutput {
total: number
data: BackOfficeCandidateListItem[]
}
export interface GetCompanyCandidatesOutput {
total: number
data: PublicCandidateInfo[]
}
export type BackendTranslationNamespace = 'cards' | 'misc' | 'outputs'
export interface BackendTranslationBundle {
locale: string
namespace: BackendTranslationNamespace
content: Record<string, unknown>
updatedAt: Date
}