chore: seminarhof customer project

This commit is contained in:
e2e
2026-07-11 08:03:49 +02:00
commit 75a531bf0c
188 changed files with 20779 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
import type { Kysely } from 'kysely'
import type { AuditLog } from '@pikku/core'
import { addDays as addDaysFns, differenceInCalendarDays, format, parseISO } from 'date-fns'
import { TZDate } from '@date-fns/tz'
import type { DB } from '../types/db.types.js'
import type { EmailService } from '../services/email.js'
import { applyBookingTransition } from './booking-status.js'
/**
* The booking lifecycle's time-driven rules, factored out so the daily cron,
* the manual "run now" RPC, and `triggerContractEmail` all share one
* implementation. All calendar math is Europe/Berlin local days; every rule is
* idempotent (guarded by `*SentAt`/`flaggedAt`/`paidOn`), so running twice in a
* day is a no-op.
*/
type LifecycleServices = {
kysely: Kysely<DB>
email: EmailService
auditLog?: AuditLog
logger: { info: (message: string) => void }
}
/** Today as a Berlin-local `YYYY-MM-DD`. */
export const berlinDate = (d: Date = new Date()): string =>
format(new TZDate(d.getTime(), 'Europe/Berlin'), 'yyyy-MM-dd')
/** The Berlin-local date portion of a stored ISO timestamp. */
export const berlinDateFromIso = (iso: string): string => berlinDate(parseISO(iso))
/** Whole calendar days from `fromYmd` to `toYmd` (both `YYYY-MM-DD`). */
export const daysBetween = (fromYmd: string, toYmd: string): number =>
differenceInCalendarDays(parseISO(toYmd), parseISO(fromYmd))
/** `YYYY-MM-DD` plus `n` calendar days. */
export const addDays = (ymd: string, n: number): string =>
format(addDaysFns(parseISO(ymd), n), 'yyyy-MM-dd')
const DEPOSIT_WINDOW_DAYS = 14
const DEPOSIT_REMINDER_DAYS = 7
/** Placeholder deposit amount (cents) until a real per-booking calc lands. */
const DEFAULT_DEPOSIT_CENTS = 30000
/**
* Lifecycle rule R1, for a single booking: ensure a deposit invoice exists,
* send the contract+deposit email, then stamp `contractSentAt`. The
* invoice-existence check (not `contractSentAt`) makes the whole sequence
* safely re-entrant even if a prior run died after the email.
*/
export async function dispatchContract(
{
kysely,
email,
auditLog,
}: Pick<LifecycleServices, 'kysely' | 'email' | 'auditLog'>,
booking: { bookingId: string; venueId: string },
todayYmd: string,
): Promise<void> {
const existing = await kysely
.selectFrom('invoice')
.where('bookingId', '=', booking.bookingId)
.where('kind', '=', 'deposit')
.select('invoiceId')
.executeTakeFirst()
if (!existing) {
await kysely
.insertInto('invoice')
.values({
invoiceId: `inv_${booking.bookingId}_dep`,
bookingId: booking.bookingId,
kind: 'deposit',
invoiceNumber: `DEP-${todayYmd}-${booking.bookingId}`,
issuedOn: todayYmd,
dueOn: addDays(todayYmd, DEPOSIT_WINDOW_DAYS),
paidOn: null,
amountCents: DEFAULT_DEPOSIT_CENTS,
status: 'outstanding',
})
.execute()
}
await email.sendContractAndDepositEmail(booking.bookingId)
const now = new Date().toISOString()
await kysely
.updateTable('booking')
.set({ contractSentAt: now })
.where('bookingId', '=', booking.bookingId)
.execute()
await auditLog?.write({
type: 'booking.email_sent',
source: 'explicit',
metadata: {
entity: 'booking',
entityId: booking.bookingId,
action: 'email_sent',
after: 'sendContractAndDepositEmail',
venueId: booking.venueId,
},
})
}
export type LifecycleSummary = {
date: string
contractsSent: number
reminders: number
flagged: number
ended: number
}
/**
* One daily sweep over active bookings, applying rules R1R4 (see
* docs/booking-lifecycle.md). Returns a counts summary for logging/dev.
*/
export async function runBookingLifecycle(
services: LifecycleServices,
): Promise<LifecycleSummary> {
const { kysely, email, logger, auditLog } = services
const today = berlinDate()
const bookings = await kysely
.selectFrom('booking')
.where('status', 'in', ['reserved', 'confirmed'])
.select([
'bookingId',
'venueId',
'status',
'startDate',
'endDate',
'contractSentAt',
'depositReminderSentAt',
'flaggedAt',
])
.execute()
const summary: LifecycleSummary = {
date: today,
contractsSent: 0,
reminders: 0,
flagged: 0,
ended: 0,
}
for (const b of bookings) {
// Lifecycle rules are all date-driven; a booking without dates (an
// enquiry that hasn't been scheduled yet) has nothing to act on.
if (b.startDate == null || b.endDate == null) continue
// R4 — auto-end a confirmed booking once its event has ended.
if (b.status === 'confirmed') {
if (b.endDate < today) {
await applyBookingTransition(services, {
bookingId: b.bookingId,
to: 'ended',
userId: null,
})
summary.ended++
}
continue
}
// status === 'reserved' below.
// R1 — dispatch the contract ~1yr before start (daily `<=`, not `==`).
if (!b.contractSentAt) {
if (daysBetween(today, b.startDate) <= 365) {
await dispatchContract(services, b, today)
summary.contractsSent++
}
continue // freshly sent — reminders/flags begin on later ticks
}
// Contract sent → drive deposit reminders/flags off the deposit invoice.
const deposit = await kysely
.selectFrom('invoice')
.where('bookingId', '=', b.bookingId)
.where('kind', '=', 'deposit')
.select(['dueOn', 'paidOn'])
.executeTakeFirst()
if (deposit?.paidOn) continue // deposit settled — nothing to chase
// contractSentAt is stored as an ISO string (see schema.d.ts).
const daysSinceContract = daysBetween(berlinDateFromIso(b.contractSentAt), today)
// R2 — day-7 reminder while the deposit is still unpaid.
if (!b.depositReminderSentAt && daysSinceContract >= DEPOSIT_REMINDER_DAYS) {
await email.sendDepositReminderEmail(b.bookingId)
const reminderNow = new Date().toISOString()
await kysely
.updateTable('booking')
.set({ depositReminderSentAt: reminderNow })
.where('bookingId', '=', b.bookingId)
.execute()
await auditLog?.write({
type: 'booking.email_sent',
source: 'explicit',
metadata: {
entity: 'booking',
entityId: b.bookingId,
action: 'email_sent',
after: 'sendDepositReminderEmail',
venueId: b.venueId,
},
})
summary.reminders++
}
// R3 — past due → FLAG ONLY (deadline = invoice.dueOn, admin-overridable).
// dueOn is stored as an ISO string; compare as Berlin YYYY-MM-DD.
if (!b.flaggedAt && deposit?.dueOn && berlinDateFromIso(deposit.dueOn) < today) {
await kysely
.updateTable('booking')
.set({ flaggedAt: new Date().toISOString(), flagReason: 'deposit_overdue' })
.where('bookingId', '=', b.bookingId)
.execute()
summary.flagged++
}
}
logger.info(`[booking-lifecycle] ${JSON.stringify(summary)}`)
return summary
}

View File

@@ -0,0 +1,136 @@
import type { Kysely } from 'kysely'
import type { AuditLog } from '@pikku/core'
import type { DB } from '../types/db.types.js'
import type { EmailService } from '../services/email.js'
import { InvalidBookingTransitionError } from '../errors.js'
/**
* The lean status "bucket". The contract/deposit progression that runs inside
* `reserved` lives in milestone columns + the deposit invoice, not as statuses.
*/
export const BOOKING_STATUSES = [
'enquiry',
'reserved',
'confirmed',
'ended',
'cancelled',
] as const
export type BookingStatus = (typeof BOOKING_STATUSES)[number]
type EmailMethod =
| 'sendEnquiryDeclinedEmail'
| 'sendReservationConfirmedEmail'
| 'sendBookingConfirmedEmail'
| 'sendCancellationEmail'
type Transition = { to: BookingStatus; email?: EmailMethod }
/**
* Single source of truth for status transitions: `from → [{ to, email }]`.
* Each transition carries the email it owns, so dispatch stays centralised in
* {@link applyBookingTransition} (no double-sends). The client mirrors the
* allowed-target shape in `lib/status.ts` for its CTA buttons; this server copy
* is the one that's enforced.
*/
export const BOOKING_TRANSITIONS: Record<BookingStatus, Transition[]> = {
enquiry: [
{ to: 'reserved', email: 'sendReservationConfirmedEmail' }, // admin approves
{ to: 'cancelled', email: 'sendEnquiryDeclinedEmail' }, // admin declines
],
reserved: [
{ to: 'confirmed', email: 'sendBookingConfirmedEmail' }, // deposit recorded
{ to: 'cancelled', email: 'sendCancellationEmail' },
],
confirmed: [
{ to: 'ended' }, // cron R4 / admin override — no email
{ to: 'cancelled', email: 'sendCancellationEmail' },
],
ended: [],
cancelled: [],
}
export const allowedTransitions = (from: BookingStatus): BookingStatus[] =>
(BOOKING_TRANSITIONS[from] ?? []).map((t) => t.to)
type TransitionServices = {
kysely: Kysely<DB>
email: EmailService
auditLog?: AuditLog
}
/**
* Apply a guarded status transition: validate against {@link BOOKING_TRANSITIONS},
* update status + updatedAt, write an audit row, and fire the email that the
* transition owns. Returns the resulting status (a no-op self-transition just
* echoes it back). `userId` is null for system (cron-driven) transitions.
*
* Milestone side-effects (deposit, contract response, AGB) belong to the
* dedicated record* RPCs, NOT here — this only moves the status bucket.
*/
export async function applyBookingTransition(
{ kysely, email, auditLog }: TransitionServices,
args: { bookingId: string; to: BookingStatus; userId: string | null },
): Promise<BookingStatus> {
const { bookingId, to, userId } = args
const booking = await kysely
.selectFrom('booking')
.where('bookingId', '=', bookingId)
.select(['bookingId', 'status', 'venueId'])
.executeTakeFirstOrThrow()
const from = booking.status as BookingStatus
if (from === to) return to
const transition = (BOOKING_TRANSITIONS[from] ?? []).find((t) => t.to === to)
if (!transition) {
throw new InvalidBookingTransitionError(from, to)
}
const now = new Date().toISOString()
const statusAtColumn: Partial<Record<'reservedAt' | 'confirmedAt' | 'endedAt' | 'cancelledAt', string>> = {}
if (to === 'reserved') statusAtColumn.reservedAt = now
if (to === 'confirmed') statusAtColumn.confirmedAt = now
if (to === 'ended') statusAtColumn.endedAt = now
if (to === 'cancelled') statusAtColumn.cancelledAt = now
await kysely
.updateTable('booking')
.set({ status: to, updatedAt: now, ...statusAtColumn })
.where('bookingId', '=', bookingId)
.execute()
await auditLog?.write({
type: 'booking.update',
source: 'explicit',
actor: userId ? { userId } : undefined,
metadata: {
entity: 'booking',
entityId: bookingId,
action: 'update',
field: 'status',
before: from,
after: to,
venueId: booking.venueId,
},
})
if (transition.email) {
await email[transition.email](bookingId)
await auditLog?.write({
type: 'booking.email_sent',
source: 'explicit',
actor: userId ? { userId } : undefined,
metadata: {
entity: 'booking',
entityId: bookingId,
action: 'email_sent',
after: transition.email,
venueId: booking.venueId,
},
})
}
return to
}

View File

@@ -0,0 +1,5 @@
import { pikkuAuth } from "#pikku";
export const isAdminOrOwner = pikkuAuth(async (_services, session) => {
return session?.role === "admin" || session?.role === "owner";
});

View File

@@ -0,0 +1,51 @@
import type { Kysely } from 'kysely'
import type { DB } from '../types/db.types.js'
/**
* Ensures a login-capable client user exists for a client org, idempotently.
*
* Finds or creates a `user` row (role `client`) keyed by lower-cased email,
* then ensures a `client_member` row links that user to the client as `organiser`.
*
* Reuse-by-email is intentional: one person who organises for several clients
* gets one login with multiple `client_member` rows.
* Returns the resolved `userId`.
*/
export async function provisionClientLogin(
kysely: Kysely<DB>,
{ clientId, email, name }: { clientId: string; email: string; name: string | null },
): Promise<string> {
const normalisedEmail = email.toLowerCase()
const existing = await kysely
.selectFrom('user')
.where('email', '=', normalisedEmail)
.select('id')
.executeTakeFirst()
const id = existing?.id ?? crypto.randomUUID()
if (!existing) {
const now = new Date().toISOString()
await kysely
.insertInto('user')
.values({
id,
email: normalisedEmail,
name: name ?? normalisedEmail,
emailVerified: 0,
createdAt: now,
updatedAt: now,
role: 'client',
locale: 'de',
} as any)
.execute()
}
await kysely
.insertInto('clientMember')
.values({ clientId, userId: id, role: 'organiser' })
.onConflict((oc) => oc.columns(['clientId', 'userId']).doNothing())
.execute()
return id
}

View File

@@ -0,0 +1,43 @@
import type { Kysely } from 'kysely'
import type { DB } from '../types/db.types.js'
import { SESSION_COOKIE } from '../middleware/session-cookie.js'
export const SESSION_DAYS = 30
export const uid = () =>
globalThis.crypto?.randomUUID?.() ??
`id_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
const isoOffsetDays = (days: number) => {
const d = new Date()
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString()
}
/**
* Creates a server-side session row for `userId` and writes the session cookie.
* Shared by password login and the passwordless booking-form magic link so both
* issue identical sessions. `http` is optional (absent off the HTTP transport).
*/
export async function createUserSession(
kysely: Kysely<DB>,
http: { response?: { cookie: (name: string, value: string, opts: Record<string, unknown>) => void } } | undefined,
userId: string,
): Promise<string> {
const sessionId = uid()
await kysely
.insertInto('bookingSession')
.values({ sessionId, userId, expiresAt: isoOffsetDays(SESSION_DAYS) })
.execute()
if (http?.response) {
http.response.cookie(SESSION_COOKIE, sessionId, {
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * SESSION_DAYS,
})
}
return sessionId
}