chore: seminarhof customer project
This commit is contained in:
226
packages/functions/src/lib/booking-lifecycle.ts
Normal file
226
packages/functions/src/lib/booking-lifecycle.ts
Normal 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 R1–R4 (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
|
||||
}
|
||||
136
packages/functions/src/lib/booking-status.ts
Normal file
136
packages/functions/src/lib/booking-status.ts
Normal 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
|
||||
}
|
||||
5
packages/functions/src/lib/permissions.ts
Normal file
5
packages/functions/src/lib/permissions.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { pikkuAuth } from "#pikku";
|
||||
|
||||
export const isAdminOrOwner = pikkuAuth(async (_services, session) => {
|
||||
return session?.role === "admin" || session?.role === "owner";
|
||||
});
|
||||
51
packages/functions/src/lib/provision-client-login.ts
Normal file
51
packages/functions/src/lib/provision-client-login.ts
Normal 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
|
||||
}
|
||||
43
packages/functions/src/lib/session.ts
Normal file
43
packages/functions/src/lib/session.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user