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 09:36:03 +02:00
commit 1e78e21c36
398 changed files with 38345 additions and 0 deletions

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

@@ -0,0 +1,102 @@
type AuthUser = {
id: string
email: string
name: string
role?: string
type?: string
}
const getApiBase = () => {
if (typeof window !== 'undefined') {
return ''
}
const viteEnv = (import.meta as ImportMeta & {
env?: Record<string, string | undefined>
}).env
return viteEnv?.VITE_API_BASE_URL || process.env.VITE_API_BASE_URL || process.env.API_URL || ''
}
const getErrorMessage = async (response: Response) => {
const text = await response.text()
if (!text) {
return `Request failed with status ${response.status}`
}
try {
const data = JSON.parse(text) as { message?: string; error?: string }
return data.message || data.error || text
} catch {
return text
}
}
const authFetch = async <T>(path: string, init?: RequestInit): Promise<T> => {
const headers = new Headers(init?.headers)
const hasBody = init?.body !== undefined && init?.body !== null
if (hasBody && !headers.has('content-type')) {
headers.set('content-type', 'application/json')
}
const response = await fetch(`${getApiBase()}${path}`, {
...init,
credentials: 'include',
headers,
})
if (!response.ok) {
throw new Error(await getErrorMessage(response))
}
if (response.status === 204) {
return undefined as T
}
return await response.json() as T
}
export const signInWithPassword = async (email: string, password: string) => {
return await authFetch<{
redirect: boolean
token: string
url?: string
user: AuthUser
}>('/api/auth/sign-in/email', {
method: 'POST',
body: JSON.stringify({
email,
password,
}),
})
}
export const requestMagicLink = async (email: string) => {
return await authFetch<{ status: boolean }>('/api/auth/sign-in/magic-link', {
method: 'POST',
body: JSON.stringify({ email }),
})
}
export const verifyMagicLink = async (token: string) => {
return await authFetch<{
token: string
user: AuthUser
session: {
id: string
userId: string
expiresAt: string
}
}>(`/api/auth/magic-link/verify?token=${encodeURIComponent(token)}`, {
method: 'GET',
})
}
export const signOut = async () => {
return await authFetch('/api/auth/sign-out', {
method: 'POST',
body: JSON.stringify({}),
})
}

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,
},
},
]
}