mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
Wochenreport
This commit is contained in:
42
server/api/service/analytics/landing.post.ts
Normal file
42
server/api/service/analytics/landing.post.ts
Normal 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 }
|
||||
})
|
||||
57
server/api/service/analytics/product-session.post.ts
Normal file
57
server/api/service/analytics/product-session.post.ts
Normal 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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
38
server/api/service/cron/weekly-kpi-report.get.ts
Normal file
38
server/api/service/cron/weekly-kpi-report.get.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user