Wochenreport

This commit is contained in:
itsrubberduck
2026-05-06 17:38:36 +02:00
parent 2bcd27c635
commit f38b47acbd
14 changed files with 954 additions and 1 deletions

View File

@@ -0,0 +1,42 @@
import { createError, readBody } from 'h3'
import { LandingAnalyticsEvent, type LandingAnalyticsEventType } from '../../../models/LandingAnalyticsEvent'
interface LandingAnalyticsBody {
type?: LandingAnalyticsEventType
product?: string
sessionId?: string
path?: string
source?: string
scrollDepth?: number
}
const allowedTypes = new Set<LandingAnalyticsEventType>(['view', 'scrolled', 'waitlist_submit'])
function cleanText(value: unknown, maxLength = 180) {
return typeof value === 'string' ? value.trim().slice(0, maxLength) : undefined
}
export default defineEventHandler(async (event) => {
const body = await readBody<LandingAnalyticsBody>(event).catch(() => ({} as LandingAnalyticsBody))
const type = body.type && allowedTypes.has(body.type) ? body.type : null
if (!type) {
throw createError({ statusCode: 400, statusMessage: 'Valid analytics event type is required.' })
}
const scrollDepth =
typeof body.scrollDepth === 'number' && Number.isFinite(body.scrollDepth)
? Math.max(0, Math.min(100, Math.round(body.scrollDepth)))
: undefined
await LandingAnalyticsEvent.create({
type,
product: body.product === 'liveatc' ? 'liveatc' : 'classroom',
sessionId: cleanText(body.sessionId, 80),
path: cleanText(body.path, 180),
source: cleanText(body.source, 180),
scrollDepth,
})
return { success: true }
})

View File

@@ -0,0 +1,57 @@
import { createError, readBody } from 'h3'
import { getUserFromEvent } from '../../../utils/auth'
import { ProductUsageSession } from '../../../models/ProductUsageSession'
interface ProductSessionBody {
product?: string
path?: string
durationSeconds?: number
startedAt?: string
endedAt?: string
}
function cleanPath(value: unknown) {
return typeof value === 'string' ? value.trim().slice(0, 180) : undefined
}
function parseDate(value: unknown, fallback: Date) {
if (typeof value !== 'string') {
return fallback
}
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? fallback : parsed
}
export default defineEventHandler(async (event) => {
const user = await getUserFromEvent(event)
const body = await readBody<ProductSessionBody>(event).catch(() => ({} as ProductSessionBody))
const product = body.product === 'liveatc' ? 'liveatc' : body.product === 'classroom' ? 'classroom' : null
if (!product) {
throw createError({ statusCode: 400, statusMessage: 'Valid product is required.' })
}
const durationSeconds =
typeof body.durationSeconds === 'number' && Number.isFinite(body.durationSeconds)
? Math.max(1, Math.min(60 * 60 * 6, Math.round(body.durationSeconds)))
: 0
if (durationSeconds < 5) {
return { success: true, ignored: true }
}
const endedAt = parseDate(body.endedAt, new Date())
const startedAt = parseDate(body.startedAt, new Date(endedAt.getTime() - durationSeconds * 1000))
await ProductUsageSession.create({
user: user?._id,
product,
path: cleanPath(body.path),
durationSeconds,
startedAt,
endedAt,
})
return { success: true }
})

View File

@@ -1,6 +1,7 @@
import mongoose from 'mongoose'
import { defineEventHandler } from 'h3'
import { InvitationCode } from '../../../models/InvitationCode'
import { KpiReportDelivery } from '../../../models/KpiReportDelivery'
import { WaitlistEntry } from '../../../models/WaitlistEntry'
import { sendMail } from '../../../utils/notifications'
import {
@@ -10,10 +11,87 @@ import {
renderInvitationEmail,
renderInvitationText,
} from '../../../utils/invitations'
import { buildWeeklyKpiReport, renderWeeklyKpiEmail, renderWeeklyKpiText } from '../../../utils/kpiReport'
const DAY_MS = 1000 * 60 * 60 * 24
const INVITATION_DELAY_DAYS = 5
const FEEDBACK_DELAY_DAYS = 14
const KPI_RECIPIENT = 'opensquawk-kpi@faktorxmensch.com'
const KPI_TIME_ZONE = 'Europe/Berlin'
const KPI_SEND_AFTER_HOUR = 9
function getBerlinDateParts(date: Date) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: KPI_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date)
const value = (type: string) => Number(parts.find((part) => part.type === type)?.value || 0)
return {
year: value('year'),
month: value('month'),
day: value('day'),
hour: value('hour'),
minute: value('minute'),
}
}
function getWeeklyKpiSchedule(now: Date) {
const local = getBerlinDateParts(now)
const localDay = new Date(Date.UTC(local.year, local.month - 1, local.day))
const dayOfWeek = localDay.getUTCDay() || 7
const monday = new Date(localDay.getTime() - (dayOfWeek - 1) * DAY_MS)
const previousMonday = new Date(monday.getTime() - 7 * DAY_MS)
const canSend = dayOfWeek > 1 || local.hour >= KPI_SEND_AFTER_HOUR
const periodStart = previousMonday
const periodEnd = monday
const weekKey = periodEnd.toISOString().slice(0, 10)
return { canSend, weekKey, periodStart, periodEnd }
}
async function sendWeeklyKpiReportIfDue(now: Date) {
const schedule = getWeeklyKpiSchedule(now)
if (!schedule.canSend) {
return { sent: false, skipped: 'before-weekly-window' }
}
const alreadySent = await KpiReportDelivery.exists({ weekKey: schedule.weekKey })
if (alreadySent) {
return { sent: false, skipped: 'already-sent' }
}
const report = await buildWeeklyKpiReport(schedule.periodEnd)
const subject = `OpenSquawk KPI Report ${report.periodStart.slice(0, 10)} - ${report.periodEnd.slice(0, 10)}`
const mailAccepted = await sendMail({
to: KPI_RECIPIENT,
subject,
text: renderWeeklyKpiText(report),
html: renderWeeklyKpiEmail(report),
})
await KpiReportDelivery.create({
weekKey: schedule.weekKey,
periodStart: new Date(report.periodStart),
periodEnd: new Date(report.periodEnd),
recipient: KPI_RECIPIENT,
sentAt: now,
mailAccepted,
})
return {
sent: true,
mailAccepted,
recipient: KPI_RECIPIENT,
periodStart: report.periodStart,
periodEnd: report.periodEnd,
}
}
export default defineEventHandler(async () => {
const now = new Date()
@@ -107,8 +185,12 @@ export default defineEventHandler(async () => {
console.log(`[waitlist-drip] invitations sent: ${invitationsSent}`)
console.log(`[waitlist-drip] feedback requests sent: ${feedbackRequests}`)
const kpiReport = await sendWeeklyKpiReportIfDue(now)
console.log(`[waitlist-drip] weekly KPI report: ${kpiReport.sent ? 'sent' : `skipped (${kpiReport.skipped})`}`)
return {
invitationsSent,
feedbackRequests,
kpiReport,
}
})

View File

@@ -0,0 +1,38 @@
import { createError, getQuery } from 'h3'
import { sendMail } from '../../../utils/notifications'
import { buildWeeklyKpiReport, renderWeeklyKpiEmail, renderWeeklyKpiText } from '../../../utils/kpiReport'
const DEFAULT_KPI_RECIPIENT = 'opensquawk-kpi@faktorxmensch.com'
export default defineEventHandler(async (event) => {
const secret = process.env.KPI_CRON_SECRET?.trim()
if (secret) {
const query = getQuery(event)
const provided = typeof query.secret === 'string' ? query.secret : ''
if (provided !== secret) {
throw createError({ statusCode: 401, statusMessage: 'Invalid KPI cron secret.' })
}
}
const report = await buildWeeklyKpiReport()
const to = process.env.KPI_EMAIL_TO || DEFAULT_KPI_RECIPIENT
const subject = `OpenSquawk KPI Report ${report.periodStart.slice(0, 10)} - ${report.periodEnd.slice(0, 10)}`
const sent = await sendMail({
to,
subject,
text: renderWeeklyKpiText(report),
html: renderWeeklyKpiEmail(report),
})
return {
success: true,
sent,
to,
periodStart: report.periodStart,
periodEnd: report.periodEnd,
totals: report.totals,
products: report.products,
smartGoals: report.smartGoals,
}
})

View File

@@ -1,4 +1,5 @@
import { createError, readBody } from 'h3'
import { FeedbackSubmission } from '../../../models/FeedbackSubmission'
import { sendAdminNotification } from '../../../utils/notifications'
interface FeedbackRequestBody {
@@ -14,6 +15,7 @@ interface FeedbackRequestBody {
hostingInterest?: string
otherIdeas?: string
contactConsent?: boolean
product?: string
}
function ensureRating(value: unknown) {
@@ -62,8 +64,25 @@ export default defineEventHandler(async (event) => {
const highlightSelections = normaliseArray(body.highlightSelections)
const frictionSelections = normaliseArray(body.frictionSelections)
const allowContact = Boolean(body.contactConsent)
const product = body.product === 'liveatc' ? 'liveatc' : 'classroom'
const fromAddress = email ? (name ? `${name} <${email}>` : email) : undefined
await FeedbackSubmission.create({
product,
name,
email,
discordHandle,
excitement,
highlightSelections,
highlightNotes,
frictionSelections,
frictionNotes,
classroomNotes,
hostingInterest,
otherIdeas,
contactConsent: allowContact,
})
const details: string[] = []
details.push(`Overall excitement: ${excitement}/5`)
details.push(`Highlights: ${highlightSelections.length ? highlightSelections.join(', ') : '—'}`)
@@ -107,6 +126,7 @@ export default defineEventHandler(async (event) => {
['Email', email || '—'],
['Discord', discordHandle || '—'],
['Okay to contact', allowContact ? 'Yes' : 'No'],
['Product', product],
],
replyTo: fromAddress,
})

View File

@@ -1,5 +1,6 @@
import { readBody, createError, getRequestURL, type H3Event } from 'h3'
import { WaitlistEntry, type WaitlistEntryDocument } from '../../models/WaitlistEntry'
import { LandingAnalyticsEvent } from '../../models/LandingAnalyticsEvent'
import { sendAdminNotification } from '../../utils/notifications'
import { registerUpdateSubscriber } from '../../utils/subscribers'
import {
@@ -14,6 +15,7 @@ interface WaitlistRequestBody {
consentPrivacy?: boolean
consentTerms?: boolean
source?: string
product?: string
wantsProductUpdates?: boolean
referralToken?: string
}
@@ -62,6 +64,7 @@ export default defineEventHandler(async (event) => {
const name = body.name?.trim()
const notes = body.notes?.trim()
const source = body.source?.trim() || 'landing'
const product = body.product === 'liveatc' ? 'liveatc' : 'classroom'
const wantsProductUpdates = Boolean(body.wantsProductUpdates)
const normalizedReferralToken = normalizeWaitlistReferralToken(body.referralToken)
const fromAddress = email ? (name ? `${name} <${email}>` : email) : undefined
@@ -102,6 +105,7 @@ export default defineEventHandler(async (event) => {
existing.name = name || existing.name
existing.notes = notes || existing.notes
existing.source = referralSource
existing.product = product
existing.consentPrivacy = true
existing.consentTerms = true
if (wantsProductUpdates && !previouslyWantedUpdates) {
@@ -164,6 +168,7 @@ export default defineEventHandler(async (event) => {
name,
notes,
source: referralSource,
product,
consentPrivacy: true,
consentTerms: true,
joinedAt: now,
@@ -173,6 +178,13 @@ export default defineEventHandler(async (event) => {
referredBy: canAttributeReferral && referrer ? (referrer._id as any) : undefined,
})
await LandingAnalyticsEvent.create({
type: 'waitlist_submit',
product,
path: '/',
source: referralSource,
}).catch(() => undefined)
if (canAttributeReferral) {
await applyReferralAttribution(referrer)
}
@@ -197,6 +209,7 @@ export default defineEventHandler(async (event) => {
dataEntries.push(['Notes', notes])
}
dataEntries.push(['Source', referralSource])
dataEntries.push(['Product', product])
dataEntries.push(['Opt-in', wantsProductUpdates ? 'Product updates' : 'Waitlist only'])
if (canAttributeReferral && referrer) {
dataEntries.push(['Referral', normalizedReferralToken as string])