-
Notifications
You must be signed in to change notification settings - Fork 0
Add playbook persistence, nudges, and dashboard #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ReturnType<typeof prisma.playbookArticle.findMany>>, | ||
| savedIds: Set<string> | ||
| ): 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: { | ||
|
Comment on lines
+23
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The page pulls Useful? React with 👍 / 👎. |
||
| 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 ( | ||
| <main className="mx-auto max-w-5xl space-y-6 py-10"> | ||
| <header className="space-y-2"> | ||
| <p className="text-sm font-semibold uppercase tracking-wide text-emerald-600">Playbook</p> | ||
| <h1 className="text-3xl font-bold text-slate-900">Personalized guidance</h1> | ||
| <p className="text-sm text-slate-600"> | ||
| Smart filters and saved items tailor the playbook to your baby's age and the goals you | ||
| care about most. | ||
| </p> | ||
| </header> | ||
| <PlaybookDashboard | ||
| userId={userId} | ||
| articles={summaries} | ||
| tags={tagNames} | ||
| babyAgeMonths={preference.babyAgeMonths} | ||
| optIns={{ | ||
| optInEmail: preference.optInEmail, | ||
| optInPush: preference.optInPush, | ||
| optInMatrix: preference.optInMatrix | ||
| }} | ||
| /> | ||
| </main> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| }); | ||
|
Comment on lines
+35
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The GET handler executes Useful? React with 👍 / 👎. |
||
|
|
||
| return NextResponse.json({ nudges }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| 'use client'; | ||
|
|
||
| import type { PlaybookArticleSummary } from './types'; | ||
|
|
||
| interface Props { | ||
| article: PlaybookArticleSummary; | ||
| onToggleSaved?: (slug: string) => Promise<void> | 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 className="flex flex-col justify-between rounded-lg border border-slate-200 bg-white p-4 shadow-sm"> | ||
| <div className="flex flex-col gap-2"> | ||
| <div className="flex items-start justify-between gap-4"> | ||
| <h3 className="text-lg font-semibold text-slate-900">{article.title}</h3> | ||
| <button | ||
| onClick={() => onToggleSaved?.(article.slug)} | ||
| className={`rounded-full border px-3 py-1 text-xs font-semibold transition-colors ${ | ||
| article.saved | ||
| ? 'border-emerald-500 bg-emerald-50 text-emerald-600' | ||
| : 'border-slate-200 bg-slate-50 text-slate-500' | ||
| }`} | ||
| > | ||
| {article.saved ? 'Saved' : 'Save'} | ||
| </button> | ||
| </div> | ||
| <p className="text-sm text-slate-600">{article.summary}</p> | ||
| <div className="flex flex-wrap items-center gap-2 text-xs text-slate-500"> | ||
| <span className="rounded-full bg-slate-100 px-2 py-1 font-medium">{ageRange}</span> | ||
| {article.tags.map((tag) => ( | ||
| <span | ||
| key={tag} | ||
| className="rounded-full bg-slate-100 px-2 py-1 font-medium capitalize" | ||
| > | ||
| {tag} | ||
| </span> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </article> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<PlaybookFilterState>(initialFilter); | ||
|
|
||
| useEffect(() => { | ||
| onChange(filter); | ||
| }, [filter, onChange]); | ||
|
|
||
| return ( | ||
| <div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm"> | ||
| <div className="grid gap-4 md:grid-cols-4 md:items-center"> | ||
| <label className="flex flex-col gap-1 text-sm font-medium text-slate-700"> | ||
| Baby age (months) | ||
| <input | ||
| type="number" | ||
| value={filter.babyAgeMonths ?? ''} | ||
| min={0} | ||
| placeholder="Any" | ||
| onChange={(event) => | ||
| setFilter((current) => ({ | ||
| ...current, | ||
| babyAgeMonths: event.target.value === '' ? null : Number(event.target.value) | ||
| })) | ||
| } | ||
| className="rounded border border-slate-200 px-3 py-2 text-sm shadow-inner" | ||
| /> | ||
| </label> | ||
| <label className="flex flex-col gap-1 text-sm font-medium text-slate-700"> | ||
| Tag | ||
| <select | ||
| value={filter.tag ?? ''} | ||
| onChange={(event) => | ||
| setFilter((current) => ({ | ||
| ...current, | ||
| tag: event.target.value === '' ? null : event.target.value | ||
| })) | ||
| } | ||
| className="rounded border border-slate-200 px-3 py-2 text-sm shadow-inner" | ||
| > | ||
| <option value="">All tags</option> | ||
| {tags.map((tag) => ( | ||
| <option key={tag} value={tag}> | ||
| {tag} | ||
| </option> | ||
| ))} | ||
| </select> | ||
| </label> | ||
| <label className="flex items-center gap-2 text-sm font-medium text-slate-700"> | ||
| <input | ||
| type="checkbox" | ||
| checked={filter.savedOnly} | ||
| onChange={(event) => | ||
| setFilter((current) => ({ ...current, savedOnly: event.target.checked })) | ||
| } | ||
| className="h-4 w-4" | ||
| /> | ||
| Saved only | ||
| </label> | ||
| <div className="text-sm text-slate-500"> | ||
| <p className="font-medium text-slate-700">Filters active</p> | ||
| <p> | ||
| {filter.tag ? `${filter.tag} • ` : ''} | ||
| {filter.babyAgeMonths !== null ? `${filter.babyAgeMonths}m ` : 'All ages '} | ||
| {filter.savedOnly ? '• Saved' : ''} | ||
| </p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both server actions accept a
userIdargument from the client and use it directly in Prisma updates. A malicious caller can invoke these actions with someone else’s ID (e.g. via?userId=on the dashboard) and change their delivery opt‑ins or saved articles without any ownership check. Instead, derive the user ID from the authenticated request context before running the mutation, or validate that the supplied ID belongs to the current user.Useful? React with 👍 / 👎.