Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
54 changes: 54 additions & 0 deletions app/(dashboard)/playbook/actions.ts
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
});
Comment on lines +12 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Server actions allow updating any user by supplying a forged userId

Both server actions accept a userId argument 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 👍 / 👎.

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');
};
83 changes: 83 additions & 0 deletions app/(dashboard)/playbook/page.tsx
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive playbook userId from session instead of query string

The page pulls userId straight from searchParams and uses it to load preferences and saved articles. Because there is no authentication or verification, any visitor can hit /playbook?userId=<someone> and retrieve another user’s saved items and opt‑in state, and the value is forwarded to client components where it is later used for mutations. The user identifier should come from the authenticated session (or the query string should be ignored) to avoid leaking account data.

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&apos;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>
);
}
52 changes: 52 additions & 0 deletions app/api/nudges/route.ts
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge GET /api/nudges leaks all users’ nudges when userId is omitted

The GET handler executes prisma.nudge.findMany with where: undefined whenever no userId query parameter is provided, returning the latest 25 nudges across every user. Because the route has no authentication, any caller can enumerate other users’ notifications. The handler should reject requests without a user identifier and ensure the identifier belongs to the authenticated user before returning data.

Useful? React with 👍 / 👎.


return NextResponse.json({ nudges });
}
47 changes: 47 additions & 0 deletions components/playbook/article-card.tsx
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>
);
};
80 changes: 80 additions & 0 deletions components/playbook/filter-bar.tsx
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>
);
};
Loading