chore: seminarhof customer project
This commit is contained in:
53
packages/functions/src/services/email.ts
Normal file
53
packages/functions/src/services/email.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Transactional email — service SHELL.
|
||||
*
|
||||
* Each method names a real lifecycle email but currently only logs and no-ops.
|
||||
* The lifecycle (status transitions + the daily cron) drives these calls; when
|
||||
* a provider is wired up later, only the bodies here change — every call site
|
||||
* stays the same. Methods are async so the future real implementation (an HTTP
|
||||
* send) is a drop-in without touching callers.
|
||||
*/
|
||||
export type EmailLogger = { info: (message: string) => void }
|
||||
|
||||
export class EmailService {
|
||||
constructor(private readonly logger: EmailLogger) {}
|
||||
|
||||
private async send(method: string, bookingId: string): Promise<void> {
|
||||
this.logger.info(`[email:shell] ${method} → booking ${bookingId} (no-op)`)
|
||||
}
|
||||
|
||||
/** Enquiry submitted — acknowledge receipt to the client. */
|
||||
sendEnquiryReceivedEmail(bookingId: string) {
|
||||
return this.send('sendEnquiryReceivedEmail', bookingId)
|
||||
}
|
||||
|
||||
/** Admin declined the enquiry. */
|
||||
sendEnquiryDeclinedEmail(bookingId: string) {
|
||||
return this.send('sendEnquiryDeclinedEmail', bookingId)
|
||||
}
|
||||
|
||||
/** Admin approved the enquiry — dates are now soft-reserved. */
|
||||
sendReservationConfirmedEmail(bookingId: string) {
|
||||
return this.send('sendReservationConfirmedEmail', bookingId)
|
||||
}
|
||||
|
||||
/** Day 0: contract (legally binding) + deposit request. Cron R1. */
|
||||
sendContractAndDepositEmail(bookingId: string) {
|
||||
return this.send('sendContractAndDepositEmail', bookingId)
|
||||
}
|
||||
|
||||
/** Day 7: deposit still unpaid — reminder. Cron R2. */
|
||||
sendDepositReminderEmail(bookingId: string) {
|
||||
return this.send('sendDepositReminderEmail', bookingId)
|
||||
}
|
||||
|
||||
/** Deposit recorded — reservation is now guaranteed. */
|
||||
sendBookingConfirmedEmail(bookingId: string) {
|
||||
return this.send('sendBookingConfirmedEmail', bookingId)
|
||||
}
|
||||
|
||||
/** Booking cancelled at a non-enquiry stage. */
|
||||
sendCancellationEmail(bookingId: string) {
|
||||
return this.send('sendCancellationEmail', bookingId)
|
||||
}
|
||||
}
|
||||
53
packages/functions/src/services/password.ts
Normal file
53
packages/functions/src/services/password.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Password hashing using WebCrypto PBKDF2-SHA256 — works on Node, browsers, and CF Workers.
|
||||
// Stored format: `pbkdf2$<iterations>$<salt_b64>$<hash_b64>`
|
||||
|
||||
const ITERATIONS = 100_000
|
||||
const KEY_LENGTH = 32 // bytes
|
||||
const SALT_LENGTH = 16
|
||||
|
||||
const b64encode = (bytes: Uint8Array) =>
|
||||
btoa(String.fromCharCode(...bytes))
|
||||
|
||||
const b64decode = (str: string) => {
|
||||
const bin = atob(str)
|
||||
const out = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
||||
return out
|
||||
}
|
||||
|
||||
async function pbkdf2(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
|
||||
const enc = new TextEncoder()
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits'],
|
||||
)
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', salt: salt as BufferSource, iterations, hash: 'SHA-256' },
|
||||
key,
|
||||
KEY_LENGTH * 8,
|
||||
)
|
||||
return new Uint8Array(bits)
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH))
|
||||
const hash = await pbkdf2(password, salt, ITERATIONS)
|
||||
return `pbkdf2$${ITERATIONS}$${b64encode(salt)}$${b64encode(hash)}`
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const parts = stored.split('$')
|
||||
if (parts.length !== 4 || parts[0] !== 'pbkdf2') return false
|
||||
const iterations = Number(parts[1])
|
||||
if (!Number.isFinite(iterations) || iterations < 1000) return false
|
||||
const salt = b64decode(parts[2])
|
||||
const expected = b64decode(parts[3])
|
||||
const actual = await pbkdf2(password, salt, iterations)
|
||||
if (actual.length !== expected.length) return false
|
||||
let diff = 0
|
||||
for (let i = 0; i < actual.length; i++) diff |= actual[i] ^ expected[i]
|
||||
return diff === 0
|
||||
}
|
||||
Reference in New Issue
Block a user