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:06:51 +02:00
commit 373b42a994
398 changed files with 38262 additions and 0 deletions

33
apps/website/lib/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import { createAuthClient } from 'better-auth/client'
import { magicLinkClient } from 'better-auth/client/plugins'
import { apiUrl } from './env'
// Better Auth mounts at /api/auth (default basePath); the client appends the
// endpoint verbatim to baseURL, so the base must include /auth — otherwise it
// emits /api/sign-in/email and 404s on both sandbox and deploy. The base MUST be
// absolute (apiUrl() never returns a bare relative path) or the client throws
// "Invalid base URL". The backend wires the magicLink plugin, so the client does
// too — candidates sign in by magic link, staff (company/backoffice) by password.
export const authClient = createAuthClient({
baseURL: `${apiUrl()}/auth`,
plugins: [magicLinkClient()],
})
export async function signInWithPassword(email: string, password: string): Promise<void> {
const { error } = await authClient.signIn.email({ email, password })
if (error) throw new Error(error.message || 'Invalid credentials')
}
export async function requestMagicLink(email: string): Promise<void> {
const { error } = await authClient.signIn.magicLink({ email })
if (error) throw new Error(error.message || 'Unable to send magic link')
}
export async function verifyMagicLink(token: string): Promise<void> {
const { error } = await authClient.magicLink.verify({ query: { token } })
if (error) throw new Error(error.message || 'Verification failed')
}
export async function signOut(): Promise<void> {
await authClient.signOut()
}

23
apps/website/lib/env.ts Normal file
View File

@@ -0,0 +1,23 @@
// Endpoints come from env, never hardcoded.
//
// In local dev VITE_API_URL is set at build time and Vite replaces the
// import.meta.env reference inline. In deployed/sandbox bundles VITE_API_URL is
// a runtime Worker binding — invisible to Vite at build time — so it's undefined
// in the bundle. We fall back to the current origin + /api, which is correct
// because the fabric dispatcher routes every /api/* path to the API on the same
// host. (VITE_API_BASE_URL is accepted as a legacy alias for local dev.)
export function apiUrl(): string {
if (typeof window !== 'undefined' && (window as Window & { __E2E_API_URL?: string }).__E2E_API_URL) {
return (window as Window & { __E2E_API_URL?: string }).__E2E_API_URL as string
}
const buildTime = import.meta.env.VITE_API_URL ?? import.meta.env.VITE_API_BASE_URL
if (import.meta.env.SSR) {
// SSR context: the auth client is only consumed in the browser. Return the
// build-time var when available or a placeholder so SSR never throws.
return buildTime ?? '/__api'
}
// Client: build-time var (local dev) or derive from the current origin. Better
// Auth's client requires an absolute base URL, so never return a bare relative
// '/api' here — '/api/auth' throws "Invalid base URL".
return buildTime ?? `${window.location.origin}/api`
}

View File

@@ -0,0 +1,3 @@
export function getHealthResponse() {
return new Response('OK')
}

View File

@@ -0,0 +1,261 @@
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
import type { UserSession } from '@heygermany/functions/src/application-types.d.js'
import type { DB, UserRole, UserType } from '@heygermany/sdk'
import { UserTypes } from '@heygermany/sdk'
import type { SupportedLocale } from '@heygermany/sdk/config'
import { LOCALES, DEFAULT_LOCALE } from '@/config'
type RequestSnapshot = {
url: string
pathname: string
search: string
headers: Headers
cookies: Map<string, string>
}
type RequestRoutingResult =
| {
type: 'redirect'
location: string
}
| {
type: 'continue'
locale: string
}
const LOCALE_EXEMPT_PREFIXES = ['/pilot']
const LOCALE_EXEMPT_LOCALES: Record<string, SupportedLocale> = {
'/pilot': 'de',
}
const getApiBase = (request: RequestSnapshot) => {
return process.env.VITE_API_BASE_URL || process.env.API_URL || new URL(request.url).origin
}
function shouldBypassRouting(pathname: string): boolean {
return (
pathname.startsWith('/api/') ||
pathname === '/api' ||
pathname.startsWith('/_build/') ||
pathname === '/_build' ||
pathname.startsWith('/_serverFn/') ||
pathname === '/_serverFn' ||
pathname.startsWith('/@') ||
pathname.startsWith('/src/') ||
pathname.startsWith('/node_modules/') ||
/\.[a-z0-9]+$/i.test(pathname)
)
}
const getCookieHeader = (cookies: Map<string, string>) => {
return Array.from(cookies.entries())
.map(([name, value]) => `${name}=${value}`)
.join('; ')
}
const getBetterAuthSession = async (
request: RequestSnapshot
): Promise<UserSession | null> => {
const response = await fetch(`${getApiBase(request)}/api/auth/get-session`, {
method: 'GET',
headers: {
cookie: getCookieHeader(request.cookies),
},
})
if (!response.ok) {
return null
}
const data = (await response.json()) as {
user?: {
id: string
name: string
role: UserRole
type: UserType
} | null
} | null
if (!data?.user) {
return null
}
return {
userId: data.user.id,
name: data.user.name,
role: data.user.role,
type: data.user.type,
}
}
function getLocale(request: RequestSnapshot): string {
try {
const cookieLocale = request.cookies.get('NEXT_LOCALE')
if (cookieLocale && LOCALES.includes(cookieLocale as SupportedLocale)) {
return cookieLocale
}
const headers = Object.fromEntries(request.headers.entries()) as Negotiator.Headers
const languages = new Negotiator({ headers }).languages()
const validLanguages = languages.filter((lang) => {
try {
Intl.getCanonicalLocales(lang)
return true
} catch {
return false
}
})
if (validLanguages.length === 0) {
return DEFAULT_LOCALE
}
return match(validLanguages, LOCALES, DEFAULT_LOCALE)
} catch (error) {
console.error('[Middleware] Error in locale detection:', error)
return DEFAULT_LOCALE
}
}
function hasLocale(pathname: string): boolean {
return LOCALES.some((locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`)
}
function isLocaleExemptPath(pathname: string): boolean {
return LOCALE_EXEMPT_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
}
function getLocaleExemptPathLocale(pathname: string): SupportedLocale | null {
for (const [prefix, locale] of Object.entries(LOCALE_EXEMPT_LOCALES)) {
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
return locale
}
}
return null
}
function getLocaleFromPath(pathname: string): string | null {
for (const locale of LOCALES) {
if (pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`) {
return locale
}
}
return null
}
function removeLocalePrefix(pathname: string): string {
const locale = getLocaleFromPath(pathname)
if (locale) {
return pathname.slice(`/${locale}`.length) || '/'
}
return pathname
}
function requiresAuth(pathname: string): { required: boolean; type?: DB.UserType } {
const pathWithoutLocale = removeLocalePrefix(pathname)
if (pathWithoutLocale.includes('/auth/login')) {
return { required: false }
}
if (pathWithoutLocale.startsWith('/backoffice')) {
return { required: true, type: UserTypes.BACKOFFICE }
}
if (pathWithoutLocale.startsWith('/company')) {
return { required: true, type: UserTypes.COMPANY }
}
if (pathWithoutLocale.startsWith('/jobs/application')) {
return { required: true, type: UserTypes.CANDIDATE }
}
return { required: false }
}
const toAbsoluteUrl = (request: RequestSnapshot, location: string) => {
return new URL(location, request.url).toString()
}
export const getResolvedLocale = (pathname: string) => {
return getLocaleFromPath(pathname) || getLocaleExemptPathLocale(pathname) || DEFAULT_LOCALE
}
export async function resolveRequestRouting(
request: RequestSnapshot
): Promise<RequestRoutingResult> {
if (shouldBypassRouting(request.pathname)) {
return {
type: 'continue',
locale: getResolvedLocale(request.pathname),
}
}
const fullUrl = request.pathname + request.search
if (!hasLocale(request.pathname) && !isLocaleExemptPath(request.pathname)) {
const locale = getLocale(request)
return {
type: 'redirect',
location: toAbsoluteUrl(request, `/${locale}${request.pathname}${request.search}`),
}
}
const userSession = await getBetterAuthSession(request)
const authCheck = requiresAuth(request.pathname)
if (authCheck.required) {
const currentLocale = getLocaleFromPath(request.pathname) || DEFAULT_LOCALE
if (userSession?.type !== UserTypes.BACKOFFICE) {
if (!userSession || userSession.type !== authCheck.type) {
let loginPath = ''
if (authCheck.type === UserTypes.BACKOFFICE) {
loginPath = `/${currentLocale}/backoffice/auth/login`
} else if (authCheck.type === UserTypes.COMPANY) {
loginPath = `/${currentLocale}/company/auth/login`
} else if (authCheck.type === UserTypes.CANDIDATE) {
loginPath = `/${currentLocale}/jobs/apply`
}
const returnUrl = encodeURIComponent(fullUrl)
return {
type: 'redirect',
location: toAbsoluteUrl(request, `${loginPath}?return=${returnUrl}`),
}
}
}
}
if (userSession) {
const pathWithoutLocale = removeLocalePrefix(request.pathname)
const currentLocale = getLocaleFromPath(request.pathname) || DEFAULT_LOCALE
if (pathWithoutLocale.includes('/auth/login')) {
let dashboardPath = ''
if (userSession.type === UserTypes.BACKOFFICE) {
dashboardPath = `/${currentLocale}/backoffice/candidates`
} else if (userSession.type === UserTypes.COMPANY) {
dashboardPath = `/${currentLocale}/company/candidates`
} else if (userSession.type === UserTypes.CANDIDATE) {
dashboardPath = `/${currentLocale}/jobs/application`
}
if (dashboardPath) {
return {
type: 'redirect',
location: toAbsoluteUrl(request, dashboardPath),
}
}
}
}
return {
type: 'continue',
locale: getResolvedLocale(request.pathname),
}
}

View File

@@ -0,0 +1,15 @@
import { SITE_URL } from '@/lib/seo'
export function getRobotsConfig() {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/'],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
}
}

225
apps/website/lib/seo.ts Normal file
View File

@@ -0,0 +1,225 @@
import type { SupportedLocale } from '@heygermany/sdk/config'
import { DEFAULT_LOCALE, LOCALES } from '@/config'
import { mTextIn } from '@/i18n/messages'
type MessageKey = string
export const SITE_NAME = 'HeyGermany'
export const BRAND_ALT_NAME = 'Hey Germany'
export const CONTACT_EMAIL = 'info@hey-germany.com'
const viteEnv = (import.meta as ImportMeta & {
env?: Record<string, string | undefined>
}).env
export const SITE_URL =
viteEnv?.VITE_SITE_URL || process.env.VITE_SITE_URL || 'https://hey-germany.com'
export const DEFAULT_SITE_TITLE = `${SITE_NAME} | Nursing Career and Qualification Check in Germany`
export const DEFAULT_SITE_DESCRIPTION =
'HeyGermany helps internationally qualified nurses check their qualifications and take the next step toward a nursing career in Germany.'
export const DEFAULT_OG_IMAGE = '/heygermany-logo.png'
export const NO_INDEX_METADATA = {
robots: {
index: false,
follow: false,
nocache: true,
googleBot: {
index: false,
follow: false,
noimageindex: true,
},
},
}
type CreatePageMetadataInput = {
locale: string
pathname?: string
title: string
description: string
imagePath?: string
index?: boolean
availableLocales?: readonly SupportedLocale[]
canonicalLocale?: SupportedLocale
}
function isSupportedLocale(locale: string): locale is SupportedLocale {
return LOCALES.includes(locale as SupportedLocale)
}
function normalizePath(pathname: string): string {
if (!pathname || pathname === '/') {
return '/'
}
return pathname.startsWith('/') ? pathname : `/${pathname}`
}
function buildRobots(index: boolean) {
return index
? {
index: true,
follow: true,
}
: NO_INDEX_METADATA.robots
}
export function normalizeLocale(locale: string): SupportedLocale {
return isSupportedLocale(locale) ? locale : DEFAULT_LOCALE
}
export function getAbsoluteUrl(pathname: string): string {
return new URL(pathname, SITE_URL).toString()
}
export function getLocalizedPath(locale: string, pathname = '/'): string {
const normalizedLocale = normalizeLocale(locale)
const normalizedPath = normalizePath(pathname)
return normalizedPath === '/'
? `/${normalizedLocale}`
: `/${normalizedLocale}${normalizedPath}`
}
export function withSiteName(title: string): string {
return title.includes(SITE_NAME) ? title : `${title} | ${SITE_NAME}`
}
export function getMessage(locale: string, key: MessageKey): string {
return mTextIn(normalizeLocale(locale), key)
}
function buildLanguageAlternates(
pathname: string,
locales: readonly SupportedLocale[]
): Record<string, string> | undefined {
if (locales.length < 2) {
return undefined
}
const alternates = Object.fromEntries(
locales.map((locale) => [locale, getAbsoluteUrl(getLocalizedPath(locale, pathname))])
) as Record<string, string>
alternates['x-default'] = getAbsoluteUrl(getLocalizedPath(DEFAULT_LOCALE, pathname))
return alternates
}
export function createPageMetadata({
locale,
pathname = '/',
title,
description,
imagePath = DEFAULT_OG_IMAGE,
index = true,
availableLocales = LOCALES,
canonicalLocale,
}: CreatePageMetadataInput) {
const resolvedLocale = normalizeLocale(locale)
const resolvedCanonicalLocale = canonicalLocale ?? resolvedLocale
const canonicalPath = getLocalizedPath(resolvedCanonicalLocale, pathname)
const resolvedTitle = withSiteName(title)
const imageUrl = getAbsoluteUrl(imagePath)
const languages = buildLanguageAlternates(pathname, availableLocales)
return {
title: resolvedTitle,
description,
alternates: {
canonical: getAbsoluteUrl(canonicalPath),
...(languages ? { languages } : {}),
},
openGraph: {
title: resolvedTitle,
description,
url: getAbsoluteUrl(canonicalPath),
siteName: SITE_NAME,
type: 'website',
images: [
{
url: imageUrl,
alt: resolvedTitle,
},
],
},
twitter: {
card: 'summary_large_image',
title: resolvedTitle,
description,
images: [imageUrl],
},
robots: buildRobots(index),
}
}
export function createHomepageMetadata(locale: string) {
const resolvedLocale = normalizeLocale(locale)
const homepageMetadata: Partial<
Record<SupportedLocale, { title: string; description: string }>
> = {
en: {
title: DEFAULT_SITE_TITLE,
description: DEFAULT_SITE_DESCRIPTION,
},
de: {
title: `${SITE_NAME} | Pflegekarriere und Qualifikationscheck in Deutschland`,
description:
'HeyGermany unterstützt internationale Pflegefachkräfte dabei, ihre Qualifikation für Deutschland zu prüfen und den nächsten Schritt in ihre Pflegekarriere zu gehen.',
},
}
const localizedBrandMetadata = homepageMetadata[resolvedLocale]
return createPageMetadata({
locale: resolvedLocale,
title: localizedBrandMetadata?.title ?? `${SITE_NAME} | ${getMessage(locale, 'website.hero.title')}`,
description: localizedBrandMetadata?.description ?? getMessage(locale, 'website.hero.subtitle'),
})
}
export function getOrganizationStructuredData() {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': `${SITE_URL}#organization`,
name: SITE_NAME,
alternateName: BRAND_ALT_NAME,
url: SITE_URL,
logo: {
'@type': 'ImageObject',
url: getAbsoluteUrl(DEFAULT_OG_IMAGE),
},
email: CONTACT_EMAIL,
description: DEFAULT_SITE_DESCRIPTION,
contactPoint: [
{
'@type': 'ContactPoint',
contactType: 'customer support',
email: CONTACT_EMAIL,
url: SITE_URL,
availableLanguage: LOCALES,
},
],
brand: {
'@type': 'Brand',
name: SITE_NAME,
alternateName: BRAND_ALT_NAME,
},
}
}
export function getWebsiteStructuredData() {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': `${SITE_URL}#website`,
url: SITE_URL,
name: SITE_NAME,
alternateName: BRAND_ALT_NAME,
description: DEFAULT_SITE_DESCRIPTION,
publisher: {
'@id': `${SITE_URL}#organization`,
},
inLanguage: LOCALES,
}
}

View File

@@ -0,0 +1,94 @@
import { DEFAULT_LOCALE, LOCALES } from '../config'
const PUBLIC_LOCALIZED_PATHS = ['/', '/about', '/jobs/apply'] as const
const LEGAL_PATHS = [
'/legal/imprint',
'/legal/privacy',
'/legal/terms-and-conditions',
] as const
type SitemapPage = {
path: string
sitemap: {
lastmod: string
changefreq: 'weekly' | 'monthly' | 'yearly'
priority: number
alternateRefs?: Array<{
href: string
hreflang: string
}>
}
}
function normalizePath(pathname: string): string {
if (!pathname || pathname === '/') {
return '/'
}
return pathname.startsWith('/') ? pathname : `/${pathname}`
}
function getLocalizedPath(locale: string, pathname = '/'): string {
const normalizedPath = normalizePath(pathname)
return normalizedPath === '/'
? `/${locale}`
: `/${locale}${normalizedPath}`
}
const getLocalizedChangeFrequency = (
pathname: (typeof PUBLIC_LOCALIZED_PATHS)[number]
): SitemapPage['sitemap']['changefreq'] => {
return pathname === '/' ? 'weekly' : 'monthly'
}
function getAlternateRefs(pathname: (typeof PUBLIC_LOCALIZED_PATHS)[number]) {
return [
...LOCALES.map((locale) => ({
href: getLocalizedPath(locale, pathname),
hreflang: locale,
})),
{
href: getLocalizedPath(DEFAULT_LOCALE, pathname),
hreflang: 'x-default',
},
]
}
export function getSitemapPages(lastModified = new Date()): SitemapPage[] {
const lastmod = lastModified.toISOString().split('T')[0]!
const localizedPages: SitemapPage[] = LOCALES.flatMap((locale) =>
PUBLIC_LOCALIZED_PATHS.map((pathname) => ({
path: getLocalizedPath(locale, pathname),
sitemap: {
lastmod,
changefreq: getLocalizedChangeFrequency(pathname),
priority: pathname === '/' ? 1 : 0.8,
alternateRefs: getAlternateRefs(pathname),
},
}))
)
const legalPages: SitemapPage[] = LEGAL_PATHS.map((pathname) => ({
path: getLocalizedPath(DEFAULT_LOCALE, pathname),
sitemap: {
lastmod,
changefreq: 'yearly',
priority: 0.3,
},
}))
return [
...localizedPages,
...legalPages,
{
path: '/pilot',
sitemap: {
lastmod,
changefreq: 'weekly',
priority: 0.8,
},
},
]
}