From ceb5116321fb8ca6455450c376997d1f5fd437fd Mon Sep 17 00:00:00 2001 From: ecatuogno1 <110216062+ecatuogno1@users.noreply.github.com> Date: Wed, 5 Nov 2025 17:21:11 -0800 Subject: [PATCH] feat: add playbook persistence and nudges --- README.md | 9 ++ app/(dashboard)/playbook/actions.ts | 54 +++++++ app/(dashboard)/playbook/page.tsx | 83 ++++++++++ app/api/nudges/route.ts | 52 +++++++ components/playbook/article-card.tsx | 47 ++++++ components/playbook/filter-bar.tsx | 80 ++++++++++ components/playbook/playbook-dashboard.tsx | 129 ++++++++++++++++ components/playbook/types.ts | 16 ++ .../playbook/hydration-tracking-basics.mdx | 23 +++ .../playbook/postpartum-reset-checklist.mdx | 23 +++ content/playbook/sleep-routine-blueprint.mdx | 28 ++++ lib/nudges/delivery.ts | 50 ++++++ lib/nudges/queue.ts | 71 +++++++++ lib/nudges/rules.ts | 146 ++++++++++++++++++ lib/nudges/types.ts | 35 +++++ lib/playbook/content.ts | 106 +++++++++++++ lib/prisma.ts | 14 ++ package.json | 15 +- prisma/schema.prisma | 101 ++++++++++++ prisma/seed.ts | 96 ++++++++++++ tsconfig.json | 27 +++- 21 files changed, 1198 insertions(+), 7 deletions(-) create mode 100644 app/(dashboard)/playbook/actions.ts create mode 100644 app/(dashboard)/playbook/page.tsx create mode 100644 app/api/nudges/route.ts create mode 100644 components/playbook/article-card.tsx create mode 100644 components/playbook/filter-bar.tsx create mode 100644 components/playbook/playbook-dashboard.tsx create mode 100644 components/playbook/types.ts create mode 100644 content/playbook/hydration-tracking-basics.mdx create mode 100644 content/playbook/postpartum-reset-checklist.mdx create mode 100644 content/playbook/sleep-routine-blueprint.mdx create mode 100644 lib/nudges/delivery.ts create mode 100644 lib/nudges/queue.ts create mode 100644 lib/nudges/rules.ts create mode 100644 lib/nudges/types.ts create mode 100644 lib/playbook/content.ts create mode 100644 lib/prisma.ts create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts diff --git a/README.md b/README.md index 0e87952..cce2543 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,12 @@ Open http://localhost:3000 in your browser to explore the product blueprint. - Implement authentication, persistence (Prisma + PostgreSQL), and API route handlers for log management. - Add real tracking forms, dashboards, and background jobs based on the outlined modules. - Containerize the application with Docker Compose including database, worker, and reverse proxy services. + +## Playbook guidance module + +- Prisma models for `PlaybookArticle`, `Tag`, `UserPreference`, and `Nudge` provide persistence for authored content and + contextual notifications. +- Seed starter content and demo preferences with `npm run prisma:db-push` followed by `npm run prisma:seed`. +- Author long-form articles in `content/playbook/*.mdx` and run the seed to sync metadata, tags, and saved bookmarks. +- Explore tailored recommendations at `/playbook` where filters surface content by age, tag, or saved status and toggle + opt-ins for email, web push, and Matrix deliveries. diff --git a/app/(dashboard)/playbook/actions.ts b/app/(dashboard)/playbook/actions.ts new file mode 100644 index 0000000..33db1de --- /dev/null +++ b/app/(dashboard)/playbook/actions.ts @@ -0,0 +1,54 @@ +'use server'; + +import { prisma } from '@/lib/prisma'; +import { revalidatePath } from 'next/cache'; + +export type OptInPayload = { + optInEmail?: boolean; + optInPush?: boolean; + optInMatrix?: boolean; +}; + +export const updateDeliveryOptIns = async (userId: string, payload: OptInPayload) => { + await prisma.userPreference.update({ + where: { userId }, + data: payload + }); + revalidatePath('/playbook'); +}; + +export const toggleSavedArticle = async (userId: string, articleSlug: string) => { + const preference = await prisma.userPreference.findUnique({ + where: { userId } + }); + + if (!preference) return; + + const article = await prisma.playbookArticle.findUnique({ + where: { slug: articleSlug } + }); + + if (!article) return; + + const existing = await prisma.savedArticle.findUnique({ + where: { + preferenceId_articleId: { + preferenceId: preference.id, + articleId: article.id + } + } + }); + + if (existing) { + await prisma.savedArticle.delete({ where: { id: existing.id } }); + } else { + await prisma.savedArticle.create({ + data: { + preferenceId: preference.id, + articleId: article.id + } + }); + } + + revalidatePath('/playbook'); +}; diff --git a/app/(dashboard)/playbook/page.tsx b/app/(dashboard)/playbook/page.tsx new file mode 100644 index 0000000..bd3e9af --- /dev/null +++ b/app/(dashboard)/playbook/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from 'next/navigation'; +import { prisma } from '@/lib/prisma'; +import { PlaybookDashboard } from '@/components/playbook/playbook-dashboard'; +import type { PlaybookArticleSummary } from '@/components/playbook/types'; + +const fallbackUserId = 'demo-user'; + +const buildArticleSummaries = ( + articles: Awaited>, + savedIds: Set +): PlaybookArticleSummary[] => + articles.map((article) => ({ + id: article.id, + slug: article.slug, + title: article.title, + summary: article.summary, + babyAgeMin: article.babyAgeMin, + babyAgeMax: article.babyAgeMax, + tags: article.tags.map((tag) => tag.tag.slug), + saved: savedIds.has(article.id) + })); + +export default async function PlaybookPage({ + searchParams +}: { + searchParams?: { userId?: string }; +}) { + const userId = searchParams?.userId ?? fallbackUserId; + + const preference = await prisma.userPreference.findUnique({ + where: { userId }, + include: { + savedArticles: true + } + }); + + if (!preference) { + notFound(); + } + + const articles = await prisma.playbookArticle.findMany({ + include: { + tags: { + include: { + tag: true + } + } + }, + orderBy: { + publishedAt: 'desc' + } + }); + + const tags = await prisma.tag.findMany({ orderBy: { name: 'asc' } }); + + const savedIds = new Set(preference.savedArticles.map((item) => item.articleId)); + const summaries = buildArticleSummaries(articles, savedIds); + const tagNames = tags.map((tag) => tag.slug); + + return ( +
+
+

Playbook

+

Personalized guidance

+

+ Smart filters and saved items tailor the playbook to your baby's age and the goals you + care about most. +

+
+ +
+ ); +} diff --git a/app/api/nudges/route.ts b/app/api/nudges/route.ts new file mode 100644 index 0000000..dbdbff8 --- /dev/null +++ b/app/api/nudges/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { evaluateMetricsForNudges } from '@/lib/nudges/rules'; +import { prisma } from '@/lib/prisma'; + +const payloadSchema = z.object({ + userId: z.string().min(1), + metrics: z + .array( + z.object({ + metric: z.string().min(1), + value: z.number(), + collectedAt: z.union([z.string(), z.number(), z.date()]) + }) + ) + .min(1) +}); + +export const runtime = 'nodejs'; + +export async function POST(request: Request) { + const json = await request.json(); + const payload = payloadSchema.parse(json); + + const metrics = payload.metrics.map((metric) => ({ + ...metric, + collectedAt: new Date(metric.collectedAt) + })); + + const jobs = await evaluateMetricsForNudges(payload.userId, metrics); + + return NextResponse.json({ enqueued: jobs.length }); +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const userId = searchParams.get('userId'); + + const nudges = await prisma.nudge.findMany({ + where: userId + ? { + preference: { + userId + } + } + : undefined, + orderBy: { createdAt: 'desc' }, + take: 25 + }); + + return NextResponse.json({ nudges }); +} diff --git a/components/playbook/article-card.tsx b/components/playbook/article-card.tsx new file mode 100644 index 0000000..beab0ef --- /dev/null +++ b/components/playbook/article-card.tsx @@ -0,0 +1,47 @@ +'use client'; + +import type { PlaybookArticleSummary } from './types'; + +interface Props { + article: PlaybookArticleSummary; + onToggleSaved?: (slug: string) => Promise | void; +} + +export const ArticleCard = ({ article, onToggleSaved }: Props) => { + const ageRange = + article.babyAgeMin !== null || article.babyAgeMax !== null + ? `${article.babyAgeMin ?? 0} - ${article.babyAgeMax ?? 12} months` + : 'All ages'; + + return ( +
+
+
+

{article.title}

+ +
+

{article.summary}

+
+ {ageRange} + {article.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+ ); +}; diff --git a/components/playbook/filter-bar.tsx b/components/playbook/filter-bar.tsx new file mode 100644 index 0000000..1c484ea --- /dev/null +++ b/components/playbook/filter-bar.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import type { PlaybookFilterState } from './types'; + +type Props = { + tags: string[]; + initialFilter: PlaybookFilterState; + onChange: (filter: PlaybookFilterState) => void; +}; + +export const FilterBar = ({ tags, initialFilter, onChange }: Props) => { + const [filter, setFilter] = useState(initialFilter); + + useEffect(() => { + onChange(filter); + }, [filter, onChange]); + + return ( +
+
+ + + +
+

Filters active

+

+ {filter.tag ? `${filter.tag} • ` : ''} + {filter.babyAgeMonths !== null ? `${filter.babyAgeMonths}m ` : 'All ages '} + {filter.savedOnly ? '• Saved' : ''} +

+
+
+
+ ); +}; diff --git a/components/playbook/playbook-dashboard.tsx b/components/playbook/playbook-dashboard.tsx new file mode 100644 index 0000000..134b35e --- /dev/null +++ b/components/playbook/playbook-dashboard.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { useMemo, useState, useTransition } from 'react'; +import { updateDeliveryOptIns, toggleSavedArticle } from '@/app/(dashboard)/playbook/actions'; +import { ArticleCard } from './article-card'; +import { FilterBar } from './filter-bar'; +import type { PlaybookArticleSummary, PlaybookFilterState } from './types'; + +type Props = { + userId: string; + articles: PlaybookArticleSummary[]; + tags: string[]; + babyAgeMonths: number | null; + optIns: { + optInEmail: boolean; + optInPush: boolean; + optInMatrix: boolean; + }; +}; + +const defaultFilter = (babyAgeMonths: number | null): PlaybookFilterState => ({ + tag: null, + savedOnly: false, + babyAgeMonths +}); + +const matchesAge = (article: PlaybookArticleSummary, age: number | null) => { + if (age === null) return true; + const min = article.babyAgeMin ?? 0; + const max = article.babyAgeMax ?? 48; + return age >= min && age <= max; +}; + +export const PlaybookDashboard = ({ userId, articles, tags, babyAgeMonths, optIns }: Props) => { + const [filter, setFilter] = useState(defaultFilter(babyAgeMonths)); + const [optimisticArticles, setOptimisticArticles] = useState(articles); + const [optInState, setOptInState] = useState(optIns); + const [isPending, startTransition] = useTransition(); + + const filteredArticles = useMemo(() => { + return optimisticArticles.filter((article) => { + if (filter.savedOnly && !article.saved) return false; + if (filter.tag && !article.tags.includes(filter.tag)) return false; + if (!matchesAge(article, filter.babyAgeMonths)) return false; + return true; + }); + }, [optimisticArticles, filter]); + + const handleFilterChange = (next: PlaybookFilterState) => { + setFilter(next); + }; + + const handleToggleSaved = (slug: string) => { + setOptimisticArticles((current) => + current.map((article) => + article.slug === slug ? { ...article, saved: !article.saved } : article + ) + ); + + startTransition(async () => { + await toggleSavedArticle(userId, slug); + }); + }; + + const handleOptInChange = (field: keyof typeof optInState, value: boolean) => { + const nextState = { ...optInState, [field]: value }; + setOptInState(nextState); + startTransition(async () => { + await updateDeliveryOptIns(userId, nextState); + }); + }; + + return ( +
+
+

Delivery channels

+

+ Toggle where contextual nudges should arrive. Updates sync instantly with the + notifications queue. +

+
+ {[ + { field: 'optInEmail', label: 'Email digests', description: 'Nightly summary with upcoming nudges.' }, + { field: 'optInPush', label: 'Web push', description: 'Lightweight nudges when metrics drift.' }, + { field: 'optInMatrix', label: 'Matrix room', description: 'Send alerts to your caregiver channel.' } + ].map(({ field, label, description }) => ( + + ))} +
+ {isPending &&

Updating preferences…

} +
+ + + +
+
+

Recommended guidance

+ {filteredArticles.length} articles +
+
+ {filteredArticles.map((article) => ( + + ))} + {filteredArticles.length === 0 && ( +

+ No guidance matches those filters yet. Try broadening your search or lower the age range. +

+ )} +
+
+
+ ); +}; diff --git a/components/playbook/types.ts b/components/playbook/types.ts new file mode 100644 index 0000000..b52c6e1 --- /dev/null +++ b/components/playbook/types.ts @@ -0,0 +1,16 @@ +export type PlaybookArticleSummary = { + id: string; + slug: string; + title: string; + summary: string; + babyAgeMin: number | null; + babyAgeMax: number | null; + tags: string[]; + saved: boolean; +}; + +export type PlaybookFilterState = { + tag: string | null; + savedOnly: boolean; + babyAgeMonths: number | null; +}; diff --git a/content/playbook/hydration-tracking-basics.mdx b/content/playbook/hydration-tracking-basics.mdx new file mode 100644 index 0000000..39f7fb7 --- /dev/null +++ b/content/playbook/hydration-tracking-basics.mdx @@ -0,0 +1,23 @@ +--- +title: Hydration Tracking Basics +slug: hydration-tracking-basics +summary: Fast checks to confirm your baby is drinking enough, plus parent hydration reminders for late-night feeds. +tags: + - hydration + - health +babyAgeMin: 0 +babyAgeMax: 12 +publishedAt: 2024-04-05 +--- + +## Key diaper benchmarks + +- Newborns should have at least 6 wet diapers per day after day five. +- Pale yellow urine is the goal—darker colors suggest dehydration. +- Log diapers in the tracker to spot dips early. + +## Parent hydration hacks + +- Keep a water bottle next to the changing station. +- Pair every feeding session with a glass of water. +- Use lightweight reminders during night feeds to avoid alarms that wake the baby. diff --git a/content/playbook/postpartum-reset-checklist.mdx b/content/playbook/postpartum-reset-checklist.mdx new file mode 100644 index 0000000..a7c9db6 --- /dev/null +++ b/content/playbook/postpartum-reset-checklist.mdx @@ -0,0 +1,23 @@ +--- +title: Postpartum Reset Checklist +slug: postpartum-reset-checklist +summary: Lightweight weekly reset for parents including sleep banking, meal prep, and emotional check-ins. +tags: + - parents + - recovery +babyAgeMin: 0 +babyAgeMax: 9 +publishedAt: 2024-04-10 +--- + +## Weekly reset agenda + +1. Prioritize a power nap for each caregiver. +2. Prep snack bins with high-protein options. +3. Schedule a ten-minute feelings download with your partner or friend. + +## Mental health guardrails + +- Add mood check-ins to your tracker three times a week. +- Lean on your Matrix support room to crowdsource ideas and vent safely. +- Reach out to your care provider if intrusive thoughts last longer than two weeks. diff --git a/content/playbook/sleep-routine-blueprint.mdx b/content/playbook/sleep-routine-blueprint.mdx new file mode 100644 index 0000000..434e0ee --- /dev/null +++ b/content/playbook/sleep-routine-blueprint.mdx @@ -0,0 +1,28 @@ +--- +title: Sleep Routine Blueprint +slug: sleep-routine-blueprint +summary: Gentle evening rituals and environmental tweaks to help babies transition into restful nighttime sleep. +tags: + - sleep + - routines +babyAgeMin: 2 +babyAgeMax: 6 +publishedAt: 2024-04-01 +--- + +## Why routines matter + +Short, predictable steps before bedtime help your baby wind down and give you a repeatable checklist when you are exhausted. + +- Keep the bedroom lighting dim 30 minutes before bedtime. +- Use the same lullaby and swaddle technique each night. +- Track wake windows in the app so you can respond before overtired cues show up. + +## Calming activities to mix and match + +1. Warm bath or steamed bathroom to relax muscles. +2. Two minutes of baby massage focusing on legs and tummy. +3. Feeding in a quiet, low-stimulation room. +4. White noise at a consistent volume. + +> Tip: Take turns with your partner to complete the routine so everyone gets a stretch break. diff --git a/lib/nudges/delivery.ts b/lib/nudges/delivery.ts new file mode 100644 index 0000000..7baf801 --- /dev/null +++ b/lib/nudges/delivery.ts @@ -0,0 +1,50 @@ +import type { NudgeChannel, UserPreference } from '@prisma/client'; +import type { DeliveryResult, NudgeJob } from './types'; + +const channelLabels: Record = { + EMAIL: 'email', + PUSH: 'web push', + MATRIX: 'Matrix', + IN_APP: 'in-app banner' +}; + +export const isChannelEnabled = (preference: UserPreference, channel: NudgeChannel) => { + switch (channel) { + case 'EMAIL': + return preference.optInEmail; + case 'PUSH': + return preference.optInPush; + case 'MATRIX': + return preference.optInMatrix; + case 'IN_APP': + return true; + default: + return false; + } +}; + +const simulateWebhook = async (job: NudgeJob) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return `${channelLabels[job.channel]} delivery enqueued`; +}; + +export const deliverNudge = async ( + preference: UserPreference, + job: NudgeJob +): Promise => { + const enabled = isChannelEnabled(preference, job.channel); + if (!enabled) { + return { + channel: job.channel, + delivered: false, + details: `${channelLabels[job.channel]} delivery suppressed (opt-out)` + }; + } + + const details = await simulateWebhook(job); + return { + channel: job.channel, + delivered: true, + details + }; +}; diff --git a/lib/nudges/queue.ts b/lib/nudges/queue.ts new file mode 100644 index 0000000..1a8d613 --- /dev/null +++ b/lib/nudges/queue.ts @@ -0,0 +1,71 @@ +import { prisma } from '@/lib/prisma'; +import type { NudgeChannel, NudgeStatus } from '@prisma/client'; +import { deliverNudge } from './delivery'; +import type { NudgeJob } from './types'; + +const queue: NudgeJob[] = []; +let processing = false; + +const persistJob = async (job: NudgeJob, status: NudgeStatus) => { + await prisma.nudge.create({ + data: { + preferenceId: job.preferenceId, + channel: job.channel, + title: job.title, + body: job.body, + triggeredBy: job.triggeredBy, + status, + scheduledAt: job.scheduledAt ?? null, + articleId: job.articleId ?? null + } + }); +}; + +const processNext = async () => { + if (processing) return; + processing = true; + while (queue.length > 0) { + const job = queue.shift()!; + const preference = await prisma.userPreference.findUnique({ + where: { id: job.preferenceId } + }); + + if (!preference) { + await persistJob(job, 'SUPPRESSED'); + continue; + } + + const delivery = await deliverNudge(preference, job); + const status: NudgeStatus = delivery.delivered ? 'SENT' : 'SUPPRESSED'; + await persistJob(job, status); + } + processing = false; +}; + +export const enqueueNudge = async (job: NudgeJob) => { + queue.push(job); + await processNext(); +}; + +export const listPendingJobs = () => [...queue]; + +export const resetQueue = () => { + queue.length = 0; + processing = false; +}; + +export const createNudgeJob = ( + preferenceId: string, + channel: NudgeChannel, + title: string, + body: string, + triggeredBy: string, + articleId?: string +): NudgeJob => ({ + preferenceId, + channel, + title, + body, + triggeredBy, + articleId +}); diff --git a/lib/nudges/rules.ts b/lib/nudges/rules.ts new file mode 100644 index 0000000..ae48eb6 --- /dev/null +++ b/lib/nudges/rules.ts @@ -0,0 +1,146 @@ +import { prisma } from '@/lib/prisma'; +import type { PlaybookArticle, UserPreference } from '@prisma/client'; +import { z } from 'zod'; +import { createNudgeJob, enqueueNudge } from './queue'; +import type { MetricSample, MetricThreshold, NudgeJob } from './types'; + +const metricSampleSchema = z.object({ + metric: z.string().min(1), + value: z.number().finite(), + collectedAt: z.coerce.date() +}); + +const thresholdSchema = z.object({ + metric: z.string(), + min: z.number().finite().optional(), + max: z.number().finite().optional(), + rollingWindowMinutes: z.number().int().positive().optional(), + channel: z.enum(['EMAIL', 'PUSH', 'MATRIX', 'IN_APP']), + articleSlug: z.string().optional(), + message: z.string(), + title: z.string() +}); + +export const defaultThresholds: MetricThreshold[] = [ + { + metric: 'wet_diapers_last_24h', + min: 6, + channel: 'PUSH', + articleSlug: 'hydration-tracking-basics', + title: 'Hydration check', + message: 'Diaper counts dipped below the recommended range. Review hydration tips.' + }, + { + metric: 'night_sleep_hours', + min: 8, + channel: 'EMAIL', + articleSlug: 'sleep-routine-blueprint', + title: 'Sleep support', + message: 'Night sleep totals look light. Here are calming routines to try tonight.' + }, + { + metric: 'parent_mood_score', + max: 3, + channel: 'MATRIX', + title: 'Check-in reminder', + message: 'Mood check-ins show a tough day. Reach out to your support circle.' + } +]; + +type EvaluationContext = { + preference: UserPreference; + thresholds: MetricThreshold[]; + metrics: MetricSample[]; +}; + +const buildArticleLookup = async (thresholds: MetricThreshold[]) => { + const slugs = thresholds + .map((threshold) => threshold.articleSlug) + .filter((slug): slug is string => Boolean(slug)); + + if (slugs.length === 0) { + return new Map(); + } + + const articles = await prisma.playbookArticle.findMany({ + where: { slug: { in: slugs } } + }); + + return new Map(articles.map((article) => [article.slug, article])); +}; + +const evaluateThreshold = ( + threshold: MetricThreshold, + sample: MetricSample +) => { + if (threshold.min !== undefined && sample.value < threshold.min) return true; + if (threshold.max !== undefined && sample.value > threshold.max) return true; + return false; +}; + +const selectLatestSample = ( + samples: MetricSample[], + metric: string +): MetricSample | undefined => { + const matches = samples.filter((sample) => sample.metric === metric); + return matches.sort((a, b) => b.collectedAt.getTime() - a.collectedAt.getTime())[0]; +}; + +const createJobsForThresholds = async ({ + preference, + thresholds, + metrics +}: EvaluationContext): Promise => { + const articleLookup = await buildArticleLookup(thresholds); + const jobs: NudgeJob[] = []; + + thresholds.forEach((threshold) => { + const sample = selectLatestSample(metrics, threshold.metric); + if (!sample) return; + if (!evaluateThreshold(threshold, sample)) return; + + const article = threshold.articleSlug + ? articleLookup.get(threshold.articleSlug) + : undefined; + + const job = createNudgeJob( + preference.id, + threshold.channel, + threshold.title, + threshold.message, + `${threshold.metric}:${sample.value}`, + article?.id + ); + + jobs.push(job); + }); + + return jobs; +}; + +export const evaluateMetricsForNudges = async ( + userId: string, + metrics: MetricSample[], + thresholds: MetricThreshold[] = defaultThresholds +) => { + const parsedMetrics = metrics.map((metric) => metricSampleSchema.parse(metric)); + const parsedThresholds = thresholds.map((threshold) => thresholdSchema.parse(threshold)); + + const preference = await prisma.userPreference.findUnique({ + where: { userId } + }); + + if (!preference) { + throw new Error(`No user preference found for ${userId}`); + } + + const jobs = await createJobsForThresholds({ + preference, + thresholds: parsedThresholds, + metrics: parsedMetrics + }); + + await Promise.all(jobs.map((job) => enqueueNudge(job))); + + return jobs; +}; diff --git a/lib/nudges/types.ts b/lib/nudges/types.ts new file mode 100644 index 0000000..fa0ed27 --- /dev/null +++ b/lib/nudges/types.ts @@ -0,0 +1,35 @@ +import type { NudgeChannel, NudgeStatus } from '@prisma/client'; + +export type MetricSample = { + metric: string; + value: number; + collectedAt: Date; +}; + +export type MetricThreshold = { + metric: string; + min?: number; + max?: number; + rollingWindowMinutes?: number; + channel: NudgeChannel; + articleSlug?: string; + message: string; + title: string; +}; + +export type NudgeJob = { + preferenceId: string; + channel: NudgeChannel; + title: string; + body: string; + triggeredBy: string; + articleId?: string; + scheduledAt?: Date; + status?: NudgeStatus; +}; + +export type DeliveryResult = { + channel: NudgeChannel; + delivered: boolean; + details?: string; +}; diff --git a/lib/playbook/content.ts b/lib/playbook/content.ts new file mode 100644 index 0000000..cc85659 --- /dev/null +++ b/lib/playbook/content.ts @@ -0,0 +1,106 @@ +import fs from 'fs/promises'; +import path from 'path'; +import matter from 'gray-matter'; +import { prisma } from '@/lib/prisma'; + +export type PlaybookFrontmatter = { + title: string; + slug: string; + summary: string; + tags: string[]; + babyAgeMin?: number; + babyAgeMax?: number; + publishedAt?: string; +}; + +export type PlaybookContent = PlaybookFrontmatter & { + body: string; +}; + +const PLAYBOOK_DIR = path.join(process.cwd(), 'content', 'playbook'); + +export const readPlaybookDirectory = async () => { + const filenames = await fs.readdir(PLAYBOOK_DIR); + return filenames + .filter((filename) => filename.endsWith('.mdx') || filename.endsWith('.md')) + .map((filename) => path.join(PLAYBOOK_DIR, filename)); +}; + +export const loadPlaybookFile = async (filepath: string): Promise => { + const file = await fs.readFile(filepath, 'utf-8'); + const { data, content } = matter(file); + + return { + title: data.title, + slug: data.slug, + summary: data.summary, + tags: data.tags ?? [], + babyAgeMin: data.babyAgeMin, + babyAgeMax: data.babyAgeMax, + publishedAt: data.publishedAt, + body: content.trim() + }; +}; + +export const loadAllPlaybookContent = async (): Promise => { + const files = await readPlaybookDirectory(); + const contents = await Promise.all(files.map((file) => loadPlaybookFile(file))); + return contents.sort((a, b) => a.title.localeCompare(b.title)); +}; + +export const syncContentToDatabase = async () => { + const entries = await loadAllPlaybookContent(); + + for (const entry of entries) { + const publishedAt = entry.publishedAt ? new Date(entry.publishedAt) : new Date(); + const article = await prisma.playbookArticle.upsert({ + where: { slug: entry.slug }, + update: { + title: entry.title, + summary: entry.summary, + content: entry.body, + babyAgeMin: entry.babyAgeMin ?? null, + babyAgeMax: entry.babyAgeMax ?? null, + publishedAt + }, + create: { + slug: entry.slug, + title: entry.title, + summary: entry.summary, + content: entry.body, + babyAgeMin: entry.babyAgeMin ?? null, + babyAgeMax: entry.babyAgeMax ?? null, + publishedAt + } + }); + + if (entry.tags?.length) { + for (const tagSlug of entry.tags) { + const titleCase = tagSlug + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + const tag = await prisma.tag.upsert({ + where: { slug: tagSlug }, + update: { name: titleCase }, + create: { slug: tagSlug, name: titleCase } + }); + + await prisma.articleTag.upsert({ + where: { + articleId_tagId: { + articleId: article.id, + tagId: tag.id + } + }, + update: {}, + create: { + articleId: article.id, + tagId: tag.id + } + }); + } + } + } +}; diff --git a/lib/prisma.ts b/lib/prisma.ts new file mode 100644 index 0000000..fd66270 --- /dev/null +++ b/lib/prisma.ts @@ -0,0 +1,14 @@ +import { PrismaClient } from '@prisma/client'; + +declare global { + // eslint-disable-next-line no-var + var prisma: PrismaClient | undefined; +} + +export const prisma = global.prisma ?? new PrismaClient(); + +if (process.env.NODE_ENV !== 'production') { + global.prisma = prisma; +} + +export type { Prisma } from '@prisma/client'; diff --git a/package.json b/package.json index 945ba3f..7b54d32 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,18 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "prisma:generate": "prisma generate", + "prisma:db-push": "prisma db push", + "prisma:seed": "prisma db seed" }, "dependencies": { + "@prisma/client": "5.12.1", + "gray-matter": "4.0.3", "next": "14.2.3", "react": "18.2.0", - "react-dom": "18.2.0" + "react-dom": "18.2.0", + "zod": "3.23.8" }, "devDependencies": { "@types/node": "20.12.7", @@ -21,7 +27,12 @@ "eslint": "8.57.0", "eslint-config-next": "14.2.3", "postcss": "8.4.38", + "prisma": "5.12.1", "tailwindcss": "3.4.3", + "tsx": "4.7.2", "typescript": "5.4.5" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..e723176 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,101 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model PlaybookArticle { + id String @id @default(cuid()) + slug String @unique + title String + summary String + content String + babyAgeMin Int? + babyAgeMax Int? + publishedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tags ArticleTag[] + savedBy SavedArticle[] + nudges Nudge[] +} + +model Tag { + id String @id @default(cuid()) + slug String @unique + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + articles ArticleTag[] +} + +model ArticleTag { + articleId String + tagId String + assignedAt DateTime @default(now()) + + article PlaybookArticle @relation(fields: [articleId], references: [id]) + tag Tag @relation(fields: [tagId], references: [id]) + + @@id([articleId, tagId]) +} + +model UserPreference { + id String @id @default(cuid()) + userId String @unique + babyAgeMonths Int? + sleepGoal Int? + hydrationGoal Int? + quietHours String? + optInEmail Boolean @default(false) + optInPush Boolean @default(false) + optInMatrix Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + savedArticles SavedArticle[] + nudges Nudge[] +} + +model SavedArticle { + id String @id @default(cuid()) + preferenceId String + articleId String + savedAt DateTime @default(now()) + + preference UserPreference @relation(fields: [preferenceId], references: [id]) + article PlaybookArticle @relation(fields: [articleId], references: [id]) + + @@unique([preferenceId, articleId]) +} + +model Nudge { + id String @id @default(cuid()) + preferenceId String + articleId String? + channel NudgeChannel + title String + body String + triggeredBy String + status NudgeStatus @default(PENDING) + scheduledAt DateTime? + createdAt DateTime @default(now()) + + preference UserPreference @relation(fields: [preferenceId], references: [id]) + article PlaybookArticle? @relation(fields: [articleId], references: [id]) +} + +enum NudgeChannel { + EMAIL + PUSH + MATRIX + IN_APP +} + +enum NudgeStatus { + PENDING + SENT + SUPPRESSED +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..c07b54b --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,96 @@ +import { prisma } from '../lib/prisma'; +import { loadAllPlaybookContent, syncContentToDatabase } from '../lib/playbook/content'; + +const seedPreferences = async () => { + const demoPreference = await prisma.userPreference.upsert({ + where: { userId: 'demo-user' }, + update: { + babyAgeMonths: 4, + sleepGoal: 10, + hydrationGoal: 6, + optInEmail: true, + optInPush: true, + optInMatrix: true, + quietHours: '21:00-07:00' + }, + create: { + userId: 'demo-user', + babyAgeMonths: 4, + sleepGoal: 10, + hydrationGoal: 6, + optInEmail: true, + optInPush: true, + optInMatrix: true, + quietHours: '21:00-07:00' + } + }); + + const partnerPreference = await prisma.userPreference.upsert({ + where: { userId: 'partner-user' }, + update: { + babyAgeMonths: 4, + optInEmail: false, + optInPush: true, + optInMatrix: false + }, + create: { + userId: 'partner-user', + babyAgeMonths: 4, + optInEmail: false, + optInPush: true, + optInMatrix: false + } + }); + + return [demoPreference, partnerPreference]; +}; + +const seedSavedArticles = async () => { + const preference = await prisma.userPreference.findUnique({ + where: { userId: 'demo-user' } + }); + + if (!preference) return; + + const articles = await prisma.playbookArticle.findMany({ + where: { slug: { in: ['sleep-routine-blueprint', 'postpartum-reset-checklist'] } } + }); + + for (const article of articles) { + await prisma.savedArticle.upsert({ + where: { + preferenceId_articleId: { + preferenceId: preference.id, + articleId: article.id + } + }, + update: {}, + create: { + preferenceId: preference.id, + articleId: article.id + } + }); + } +}; + +export const seedPlaybook = async () => { + const content = await loadAllPlaybookContent(); + console.log(`Loaded ${content.length} playbook entries from disk.`); + await syncContentToDatabase(); + await seedPreferences(); + await seedSavedArticles(); + console.log('Seed data synced.'); +}; + +const main = async () => { + await seedPlaybook(); +}; + +main() + .catch((error) => { + console.error('Seed failed', error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/tsconfig.json b/tsconfig.json index 4dd2c38..bdd41d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": false, "skipLibCheck": true, "strict": true, @@ -16,10 +20,23 @@ "incremental": true, "baseUrl": ".", "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] }, - "plugins": [{ "name": "next" }] + "plugins": [ + { + "name": "next" + } + ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] }