diff --git a/CHANGELOG.md b/CHANGELOG.md index d59eb02..65e6f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to VouchNet are documented here. This project follows ## Unreleased +### Saved posts and notification inbox + +- Added saved posts as a real vertical slice: migration `0040_saved_posts`, `/api/saved`, feed Save + controls, and a private `/saved` page that re-checks visibility and block rules on every read. +- Added mark-all-as-read for member notifications at `/api/notifications/read-all`. +- Updated the implementation matrix and authenticated-app docs so messaging and saved content are + no longer described as missing stubs. + ## 1.5.0 - 2026-10-28 ### Desktop signal desk diff --git a/apps/web/app/api/notifications/read-all/route.ts b/apps/web/app/api/notifications/read-all/route.ts new file mode 100644 index 0000000..622cfcb --- /dev/null +++ b/apps/web/app/api/notifications/read-all/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { markAllMemberNotificationsRead } from '../../../lib/daily-strategy'; +import { actorFromRequest } from '../../../lib/identity'; +import { hasSameOrigin } from '../../../lib/request-security'; + +export async function POST(request: NextRequest) { + if (!hasSameOrigin(request)) + return NextResponse.json({ error: 'CSRF_REJECTED' }, { status: 403 }); + try { + const actor = await actorFromRequest(request); + if (actor === null) return NextResponse.json({ error: 'UNAUTHENTICATED' }, { status: 401 }); + const updated = await markAllMemberNotificationsRead(actor.userId); + return NextResponse.json({ updated }); + } catch { + return NextResponse.json({ error: 'NOTIFICATIONS_UNAVAILABLE' }, { status: 503 }); + } +} diff --git a/apps/web/app/api/saved/[postId]/route.ts b/apps/web/app/api/saved/[postId]/route.ts new file mode 100644 index 0000000..73e480b --- /dev/null +++ b/apps/web/app/api/saved/[postId]/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { actorFromRequest } from '../../../lib/identity'; +import { hasSameOrigin } from '../../../lib/request-security'; +import { unsavePost } from '../../../lib/saved-posts'; +import { enforceRateLimit } from '../../../lib/security/rate-limit'; +import { rateLimitResponse } from '../../../lib/security/rate-limit-response'; + +export async function DELETE( + request: NextRequest, + context: { params: Promise<{ postId: string }> }, +) { + if (!hasSameOrigin(request)) + return NextResponse.json({ error: 'CSRF_REJECTED' }, { status: 403 }); + try { + const actor = await actorFromRequest(request); + if (actor === null) return NextResponse.json({ error: 'UNAUTHENTICATED' }, { status: 401 }); + const rateLimit = await enforceRateLimit(request, 'socialWrite', actor.userId); + if (!rateLimit.allowed) return rateLimitResponse(rateLimit); + const { postId } = await context.params; + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(postId)) { + return NextResponse.json({ error: 'INVALID_POST' }, { status: 400 }); + } + const removed = await unsavePost(actor.userId, postId); + return NextResponse.json({ saved: false, removed }); + } catch { + return NextResponse.json({ error: 'UNSAVE_FAILED' }, { status: 400 }); + } +} diff --git a/apps/web/app/api/saved/route.test.ts b/apps/web/app/api/saved/route.test.ts new file mode 100644 index 0000000..e8b82ba --- /dev/null +++ b/apps/web/app/api/saved/route.test.ts @@ -0,0 +1,120 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GET, POST } from './route'; + +const state = vi.hoisted(() => ({ + actor: { userId: 'user-1' } as { userId: string } | null, + posts: [] as Array<{ + id: string; + authorId: string; + authorName: string; + authorSlug: string; + authorHeadline: string | null; + bodyMarkdown: string; + category: string; + createdAt: Date; + savedAt: Date; + peerSignal: number; + commentCount: number; + }>, + saveResult: 'saved' as 'saved' | 'unchanged', + fail: false, + allowed: true, +})); + +vi.mock('../../lib/identity', () => ({ + actorFromRequest: async () => state.actor, +})); + +vi.mock('../../lib/request-security', () => ({ + hasSameOrigin: () => true, +})); + +vi.mock('../../lib/security/rate-limit', () => ({ + enforceRateLimit: async () => ({ allowed: state.allowed, retryAfterSeconds: 60 }), +})); + +vi.mock('../../lib/security/rate-limit-response', () => ({ + rateLimitResponse: () => new Response(JSON.stringify({ error: 'RATE_LIMITED' }), { status: 429 }), +})); + +vi.mock('../../lib/saved-posts', () => ({ + listSavedPosts: async () => { + if (state.fail) throw new Error('DATABASE_UNAVAILABLE'); + return state.posts; + }, + savePost: async () => { + if (state.fail) throw new Error('DATABASE_UNAVAILABLE'); + return state.saveResult; + }, +})); + +describe('saved posts API', () => { + beforeEach(() => { + state.actor = { userId: 'user-1' }; + state.fail = false; + state.allowed = true; + state.saveResult = 'saved'; + state.posts = [ + { + id: '11111111-1111-4111-8111-111111111111', + authorId: 'user-2', + authorName: 'Ada Example', + authorSlug: 'ada', + authorHeadline: 'Builder', + bodyMarkdown: 'Useful note', + category: 'TECHNICAL', + createdAt: new Date('2026-10-28T12:00:00.000Z'), + savedAt: new Date('2026-10-28T13:00:00.000Z'), + peerSignal: 2, + commentCount: 1, + }, + ]; + }); + + it('lists saved posts with ISO timestamps', async () => { + const response = await GET(new NextRequest('https://vouchnet.dev/api/saved')); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + posts: [ + { + id: '11111111-1111-4111-8111-111111111111', + authorId: 'user-2', + authorName: 'Ada Example', + authorSlug: 'ada', + authorHeadline: 'Builder', + bodyMarkdown: 'Useful note', + category: 'TECHNICAL', + createdAt: '2026-10-28T12:00:00.000Z', + savedAt: '2026-10-28T13:00:00.000Z', + peerSignal: 2, + commentCount: 1, + }, + ], + }); + }); + + it('requires authentication to save', async () => { + state.actor = null; + const response = await POST( + new NextRequest('https://vouchnet.dev/api/saved', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'https://vouchnet.dev' }, + body: JSON.stringify({ postId: '11111111-1111-4111-8111-111111111111' }), + }), + ); + expect(response.status).toBe(401); + }); + + it('saves a visible post id', async () => { + const response = await POST( + new NextRequest('https://vouchnet.dev/api/saved', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'https://vouchnet.dev' }, + body: JSON.stringify({ postId: '11111111-1111-4111-8111-111111111111' }), + }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ saved: true, result: 'saved' }); + }); +}); diff --git a/apps/web/app/api/saved/route.ts b/apps/web/app/api/saved/route.ts new file mode 100644 index 0000000..c8c0c60 --- /dev/null +++ b/apps/web/app/api/saved/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { actorFromRequest } from '../../lib/identity'; +import { hasSameOrigin } from '../../lib/request-security'; +import { listSavedPosts, savePost } from '../../lib/saved-posts'; +import { enforceRateLimit } from '../../lib/security/rate-limit'; +import { rateLimitResponse } from '../../lib/security/rate-limit-response'; + +const saveSchema = z + .object({ + postId: z.string().uuid(), + }) + .strict(); + +export async function GET(request: NextRequest) { + try { + const actor = await actorFromRequest(request); + if (actor === null) return NextResponse.json({ error: 'UNAUTHENTICATED' }, { status: 401 }); + const posts = await listSavedPosts(actor.userId); + return NextResponse.json({ + posts: posts.map((post) => ({ + ...post, + createdAt: post.createdAt.toISOString(), + savedAt: post.savedAt.toISOString(), + })), + }); + } catch { + return NextResponse.json({ error: 'SAVED_UNAVAILABLE' }, { status: 503 }); + } +} + +export async function POST(request: NextRequest) { + if (!hasSameOrigin(request)) + return NextResponse.json({ error: 'CSRF_REJECTED' }, { status: 403 }); + try { + const actor = await actorFromRequest(request); + if (actor === null) return NextResponse.json({ error: 'UNAUTHENTICATED' }, { status: 401 }); + const rateLimit = await enforceRateLimit(request, 'socialWrite', actor.userId); + if (!rateLimit.allowed) return rateLimitResponse(rateLimit); + const body = saveSchema.parse(await request.json()); + const result = await savePost(actor.userId, body.postId); + return NextResponse.json({ saved: true, result }); + } catch (error) { + if (error instanceof z.ZodError) + return NextResponse.json({ error: 'INVALID_SAVE' }, { status: 400 }); + if (error instanceof Error && error.message === 'POST_NOT_FOUND') + return NextResponse.json({ error: 'POST_NOT_FOUND' }, { status: 404 }); + return NextResponse.json({ error: 'SAVE_FAILED' }, { status: 400 }); + } +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 7799429..9818e01 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -2092,6 +2092,109 @@ button:not([class]):hover { gap: 10px; align-items: center; } +.post-header > div:nth-child(2) { + flex: 1; + min-width: 0; +} +.post-save-control { + flex: 0 0 auto; + min-height: 34px; + margin-left: auto; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 10px; + color: var(--ink); + background: var(--surface); + font-size: 0.74rem; + font-weight: 760; +} +.post-save-control:hover, +.post-save-control:focus-visible { + border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); + outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent); + outline-offset: 1px; +} +.post-save-control.is-saved { + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); + color: var(--accent-strong, var(--accent)); + background: var(--accent-soft, #e8efff); +} +.saved-page { + display: grid; + gap: 18px; + max-width: 760px; +} +.saved-heading h1 { + margin: 8px 0 8px; + letter-spacing: -0.04em; +} +.saved-heading p { + margin: 0; + color: var(--muted); + line-height: 1.55; +} +.saved-empty, +.saved-loading { + padding: 28px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface); +} +.saved-empty h2 { + margin: 0 0 8px; +} +.saved-empty p { + margin: 0 0 16px; + color: var(--muted); +} +.saved-list { + display: grid; + gap: 12px; +} +.saved-card { + padding: 16px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface); +} +.saved-card header { + display: flex; + gap: 12px; + align-items: flex-start; + justify-content: space-between; +} +.saved-card header a { + color: var(--ink); + font-weight: 800; + text-decoration: none; +} +.saved-card header p, +.saved-card header time { + display: block; + margin: 3px 0 0; + color: var(--muted); + font-size: 0.76rem; +} +.saved-card > p { + margin: 14px 0; + white-space: pre-wrap; + line-height: 1.55; +} +.saved-card footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 12px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 0.76rem; +} +.saved-card footer a { + color: var(--accent-strong, var(--accent)); + font-weight: 760; + text-decoration: none; +} .post-avatar { display: grid; width: 40px; @@ -3756,6 +3859,19 @@ button.signal-cell:focus-visible { color: #66758c; } +.notification-inbox-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +.notification-inbox-toolbar p { + margin: 0; + color: var(--muted); + font-size: 0.8rem; + font-weight: 700; +} .notification-inbox { overflow: hidden; } diff --git a/apps/web/app/lib/daily-strategy.ts b/apps/web/app/lib/daily-strategy.ts index eb14b39..27aac47 100644 --- a/apps/web/app/lib/daily-strategy.ts +++ b/apps/web/app/lib/daily-strategy.ts @@ -188,3 +188,17 @@ export async function markMemberNotificationRead( await sql.end({ timeout: 1 }); } } + +export async function markAllMemberNotificationsRead(userId: string): Promise { + const sql = createSqlClient(databaseUrl()); + try { + const rows = await sql<{ id: string }[]>` + UPDATE member_notifications SET read_at=now() + WHERE user_id=${userId} AND read_at IS NULL + RETURNING id + `; + return rows.length; + } finally { + await sql.end({ timeout: 1 }); + } +} diff --git a/apps/web/app/lib/saved-posts.ts b/apps/web/app/lib/saved-posts.ts new file mode 100644 index 0000000..685a9b7 --- /dev/null +++ b/apps/web/app/lib/saved-posts.ts @@ -0,0 +1,185 @@ +import 'server-only'; +import { createSqlClient } from '@nexus/db'; + +function databaseUrl() { + const value = process.env.DATABASE_URL; + if (value === undefined) throw new Error('DATABASE_UNAVAILABLE'); + return value; +} + +export type SavedPost = { + id: string; + authorId: string; + authorName: string; + authorSlug: string; + authorHeadline: string | null; + bodyMarkdown: string; + category: string; + createdAt: Date; + savedAt: Date; + peerSignal: number; + commentCount: number; +}; + +async function assertPostVisibleToViewer( + sql: ReturnType, + viewerId: string, + postId: string, +): Promise { + const rows = await sql<{ id: string }[]>` + SELECT p.id + FROM posts p + WHERE p.id=${postId} + AND p.status='PUBLISHED' + AND p.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM blocks b + WHERE (b.blocker_id=${viewerId} AND b.blocked_id=p.author_id) + OR (b.blocker_id=p.author_id AND b.blocked_id=${viewerId}) + ) + AND ( + p.author_id=${viewerId} + OR p.visibility IN ('PUBLIC','MEMBERS') + OR ( + p.visibility='FOLLOWERS' + AND EXISTS ( + SELECT 1 FROM follows f + WHERE f.follower_id=${viewerId} AND f.followed_id=p.author_id + ) + ) + OR ( + p.visibility='CONTACTS' + AND EXISTS ( + SELECT 1 FROM connections c + WHERE c.state='ACCEPTED' + AND ( + (c.requester_id=${viewerId} AND c.recipient_id=p.author_id) + OR (c.recipient_id=${viewerId} AND c.requester_id=p.author_id) + ) + ) + ) + ) + `; + return rows[0] !== undefined; +} + +export async function listSavedPosts(viewerId: string): Promise { + const sql = createSqlClient(databaseUrl()); + try { + return await sql` + SELECT + p.id, + p.author_id AS "authorId", + concat(pr.first_name,' ',pr.last_name) AS "authorName", + pr.slug AS "authorSlug", + pr.headline AS "authorHeadline", + p.body_markdown AS "bodyMarkdown", + p.feed_category AS category, + p.created_at AS "createdAt", + s.created_at AS "savedAt", + COALESCE(( + SELECT sum( + CASE r.reaction_type + WHEN 'VERIFY' THEN 4 + WHEN 'BENCHMARK' THEN 3 + WHEN 'INSIGHTFUL' THEN 2 + ELSE 1 + END + )::int + FROM post_reactions r + JOIN connections c + ON c.state='ACCEPTED' + AND ( + (c.requester_id=r.user_id AND c.recipient_id=p.author_id) + OR (c.recipient_id=r.user_id AND c.requester_id=p.author_id) + ) + WHERE r.post_id=p.id + ),0) AS "peerSignal", + ( + SELECT count(*)::int + FROM post_comments pc + WHERE pc.post_id=p.id AND pc.deleted_at IS NULL + ) AS "commentCount" + FROM saved_posts s + JOIN posts p ON p.id=s.post_id + JOIN profiles pr ON pr.user_id=p.author_id + WHERE s.user_id=${viewerId} + AND p.status='PUBLISHED' + AND p.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM blocks b + WHERE (b.blocker_id=${viewerId} AND b.blocked_id=p.author_id) + OR (b.blocker_id=p.author_id AND b.blocked_id=${viewerId}) + ) + AND ( + p.author_id=${viewerId} + OR p.visibility IN ('PUBLIC','MEMBERS') + OR ( + p.visibility='FOLLOWERS' + AND EXISTS ( + SELECT 1 FROM follows f + WHERE f.follower_id=${viewerId} AND f.followed_id=p.author_id + ) + ) + OR ( + p.visibility='CONTACTS' + AND EXISTS ( + SELECT 1 FROM connections c + WHERE c.state='ACCEPTED' + AND ( + (c.requester_id=${viewerId} AND c.recipient_id=p.author_id) + OR (c.recipient_id=${viewerId} AND c.requester_id=p.author_id) + ) + ) + ) + ) + ORDER BY s.created_at DESC + LIMIT 100 + `; + } finally { + await sql.end({ timeout: 1 }); + } +} + +export async function savePost(viewerId: string, postId: string): Promise<'saved' | 'unchanged'> { + const sql = createSqlClient(databaseUrl()); + try { + const visible = await assertPostVisibleToViewer(sql, viewerId, postId); + if (!visible) throw new Error('POST_NOT_FOUND'); + const rows = await sql<{ post_id: string }[]>` + INSERT INTO saved_posts (user_id, post_id) + VALUES (${viewerId}, ${postId}) + ON CONFLICT (user_id, post_id) DO NOTHING + RETURNING post_id + `; + return rows[0] === undefined ? 'unchanged' : 'saved'; + } finally { + await sql.end({ timeout: 1 }); + } +} + +export async function unsavePost(viewerId: string, postId: string): Promise { + const sql = createSqlClient(databaseUrl()); + try { + const rows = await sql<{ post_id: string }[]>` + DELETE FROM saved_posts + WHERE user_id=${viewerId} AND post_id=${postId} + RETURNING post_id + `; + return rows.length === 1; + } finally { + await sql.end({ timeout: 1 }); + } +} + +export async function isPostSaved(viewerId: string, postId: string): Promise { + const sql = createSqlClient(databaseUrl()); + try { + const rows = await sql<{ post_id: string }[]>` + SELECT post_id FROM saved_posts WHERE user_id=${viewerId} AND post_id=${postId} + `; + return rows[0] !== undefined; + } finally { + await sql.end({ timeout: 1 }); + } +} diff --git a/apps/web/app/modules/posts/feed-client.tsx b/apps/web/app/modules/posts/feed-client.tsx index d6fc619..0676e29 100644 --- a/apps/web/app/modules/posts/feed-client.tsx +++ b/apps/web/app/modules/posts/feed-client.tsx @@ -88,9 +88,11 @@ function MarkdownBody({ body }: { body: string }) { function PostCard({ post, onReaction, + onToggleSave, }: { post: FeedPost; onReaction: (postId: string, reaction: ReactionType) => void; + onToggleSave: (postId: string, currentlySaved: boolean) => void; }) { return ( @@ -116,6 +118,14 @@ function PostCard({ {new Date(post.createdAt).toLocaleString()} + {post.postType === 'INTERACTIVE' && @@ -373,6 +383,41 @@ export function FeedClient() { setStatus('That reaction could not be saved.'); } } + async function toggleSave(postId: string, currentlySaved: boolean) { + const previousPosts = posts; + setPosts( + (current) => + current?.map((post) => + post.id === postId ? { ...post, viewerSaved: !currentlySaved } : post, + ) ?? null, + ); + try { + const response = currentlySaved + ? await fetch(`/api/saved/${postId}`, { method: 'DELETE' }) + : await fetch('/api/saved', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ postId }), + }); + if (!response.ok) { + setPosts(previousPosts); + setStatus( + currentlySaved + ? 'That post could not be removed from Saved.' + : 'That post could not be saved.', + ); + return; + } + setStatus(null); + } catch { + setPosts(previousPosts); + setStatus( + currentlySaved + ? 'That post could not be removed from Saved.' + : 'That post could not be saved.', + ); + } + } function toggleCategory(category: PostCategory) { setHidden((current) => current.includes(category) @@ -734,6 +779,7 @@ export function FeedClient() { key={post.id} post={post} onReaction={(postId, reactionType) => void react(postId, reactionType)} + onToggleSave={(postId, currentlySaved) => void toggleSave(postId, currentlySaved)} /> ))} diff --git a/apps/web/app/modules/posts/service.ts b/apps/web/app/modules/posts/service.ts index 8ffe7cc..c79171a 100644 --- a/apps/web/app/modules/posts/service.ts +++ b/apps/web/app/modules/posts/service.ts @@ -47,6 +47,7 @@ export interface FeedPost { bodyMarkdown: string; codeSnippets: CodeSnippet[]; mediaUrls: string[]; + viewerSaved: boolean; category: PostCategory; visibility: PostVisibility; createdAt: Date; @@ -159,6 +160,7 @@ export async function getFeed( COALESCE((SELECT sum(CASE r.reaction_type WHEN 'VERIFY' THEN 4 WHEN 'BENCHMARK' THEN 3 WHEN 'INSIGHTFUL' THEN 2 ELSE 1 END)::int FROM post_reactions r JOIN connections c ON c.state='ACCEPTED' AND ((c.requester_id=r.user_id AND c.recipient_id=p.author_id) OR (c.recipient_id=r.user_id AND c.requester_id=p.author_id)) WHERE r.post_id=p.id),0) AS "peerSignal", (SELECT count(*)::int FROM post_comments pc WHERE pc.post_id=p.id AND pc.deleted_at IS NULL) AS "commentCount", (SELECT reaction_type FROM post_reactions vr WHERE vr.post_id=p.id AND vr.user_id=${viewerId}) AS "viewerReaction", + EXISTS (SELECT 1 FROM saved_posts sp WHERE sp.post_id=p.id AND sp.user_id=${viewerId}) AS "viewerSaved", jsonb_build_object( 'LIKE',(SELECT count(*)::int FROM post_reactions rr WHERE rr.post_id=p.id AND rr.reaction_type='LIKE'), 'LOVE',(SELECT count(*)::int FROM post_reactions rr WHERE rr.post_id=p.id AND rr.reaction_type='LOVE'), diff --git a/apps/web/app/notifications/notification-inbox.tsx b/apps/web/app/notifications/notification-inbox.tsx index 3309515..9dce94d 100644 --- a/apps/web/app/notifications/notification-inbox.tsx +++ b/apps/web/app/notifications/notification-inbox.tsx @@ -7,7 +7,11 @@ import type { MemberNotification } from '../lib/daily-strategy'; export function NotificationInbox({ notifications }: { notifications: MemberNotification[] }) { const [items, setItems] = useState(notifications); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(null); const router = useRouter(); + const unreadCount = items.filter((notification) => notification.readAt === null).length; + async function markRead(id: string): Promise { const original = items.find((notification) => notification.id === id); if (original === undefined || original.readAt !== null) return true; @@ -29,12 +33,56 @@ export function NotificationInbox({ notifications }: { notifications: MemberNoti return false; } } + + async function markAllRead() { + if (unreadCount === 0 || busy) return; + const previous = items; + setBusy(true); + setItems((current) => + current.map((notification) => + notification.readAt === null ? { ...notification, readAt: new Date() } : notification, + ), + ); + try { + const response = await fetch('/api/notifications/read-all', { method: 'POST' }); + if (!response.ok) throw new Error('NOTIFICATION_READ_FAILED'); + setStatus(null); + router.refresh(); + } catch { + setItems(previous); + setStatus('Notifications could not be marked as read.'); + } finally { + setBusy(false); + } + } + async function openNotification(notification: MemberNotification) { await markRead(notification.id); router.push(notification.href); } + return (
+
+

+ {unreadCount === 0 + ? 'All caught up' + : `${unreadCount} unread notification${unreadCount === 1 ? '' : 's'}`} +

+ +
+ {status ? ( +

+ {status} +

+ ) : null} {items.map((notification) => (
- ({ + ...post, + createdAt: post.createdAt.toISOString(), + savedAt: post.savedAt.toISOString(), + }))} /> ); diff --git a/apps/web/app/saved/saved-client.tsx b/apps/web/app/saved/saved-client.tsx new file mode 100644 index 0000000..aa0d6c6 --- /dev/null +++ b/apps/web/app/saved/saved-client.tsx @@ -0,0 +1,87 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +type SavedPost = { + id: string; + authorName: string; + authorSlug: string; + authorHeadline: string | null; + bodyMarkdown: string; + category: string; + createdAt: string; + savedAt: string; + peerSignal: number; + commentCount: number; +}; + +export function SavedClient({ initialPosts }: { initialPosts: SavedPost[] }) { + const [posts, setPosts] = useState(initialPosts); + const [status, setStatus] = useState(null); + + async function remove(postId: string) { + const previous = posts; + setPosts((current) => current.filter((post) => post.id !== postId)); + try { + const response = await fetch(`/api/saved/${postId}`, { method: 'DELETE' }); + if (!response.ok) throw new Error('UNSAVE_FAILED'); + setStatus(null); + } catch { + setPosts(previous); + setStatus('That post could not be removed from Saved.'); + } + } + + return ( +
+
+

Saved

+

Keep useful work close.

+

Posts you save stay private to your account and respect visibility and block rules.

+
+ {status ? ( +

+ {status} +

+ ) : null} + {posts.length === 0 ? ( +
+

Nothing saved yet.

+

Use Save on a feed post to keep technical notes and project updates for later.

+ + Open your feed + +
+ ) : ( +
+ {posts.map((post) => ( +
+
+
+ {post.authorName} +

+ {post.authorHeadline ?? 'VouchNet member'} · {post.category.toLowerCase()} +

+ +
+ +
+

{post.bodyMarkdown}

+ +
+ ))} +
+ )} +
+ ); +} diff --git a/docs/AUTHENTICATED_APP.md b/docs/AUTHENTICATED_APP.md index a2de2f8..03408bc 100644 --- a/docs/AUTHENTICATED_APP.md +++ b/docs/AUTHENTICATED_APP.md @@ -5,12 +5,15 @@ existing in the browser is insufficient. Invalid or absent sessions redirect thr `/login`. Implemented product routes include `/home`, `/network`, `/network/discover`, `/search`, -`/in/[slug]`, `/onboarding`, `/feed`, `/jobs`, `/jobs/tracker`, `/notifications`, `/games`, -`/projects`, `/saved`, and `/settings`. The messaging route remains deliberately unavailable until -conversation persistence, membership authorization, delivery, and abuse controls are complete; it -does not display fabricated records. +`/in/[slug]`, `/onboarding`, `/feed`, `/messaging`, `/saved`, `/jobs`, `/jobs/tracker`, +`/notifications`, `/games`, `/projects`, `/discover`, `/opportunities`, and `/settings`. + +Messaging is a real vertical slice: conversation persistence, membership authorization, delivery +and seen markers, and the Desktop device-token routes. Saved posts are private to the member who +saved them and re-check visibility and block rules on every read. The avatar menu links to the real profile, projects, saved items, settings, and the real POST logout route. The protected shell includes responsive primary navigation, an accessible keyboard skip link, route-level loading skeletons, error boundaries, and a daily-notification badge. Daily notification materialization and the unread count share one database lifecycle per authenticated shell render. +Members can mark one notification or all unread notifications as read from `/notifications`. diff --git a/docs/FEED.md b/docs/FEED.md index 356c279..d5251fe 100644 --- a/docs/FEED.md +++ b/docs/FEED.md @@ -14,7 +14,11 @@ create aggregated peer-endorsement events. The reaction picker is keyboard-acces a selection, and reverts optimistic UI state if persistence fails. Quote posts require at least 80 characters server-side. +Members can save a visible post from the feed; Saved items live at `/saved` and remain private to +that account. Visibility and block checks are reapplied whenever Saved is listed, so a save cannot +bypass an audience change or a later block. + Comments, project/organization mentions, cursor pagination, safe rich Markdown/LaTeX rendering, -media ingestion, post editing/deletion, feed tests, and PostgreSQL integration verification remain -incomplete. A failed feed refresh presents an actionable status and successful refreshes clear that -status rather than leaving stale failure messaging on screen. +media ingestion, post editing/deletion, broader feed tests, and PostgreSQL integration verification +remain incomplete. A failed feed refresh presents an actionable status and successful refreshes +clear that status rather than leaving stale failure messaging on screen. diff --git a/docs/IMPLEMENTATION_MATRIX.md b/docs/IMPLEMENTATION_MATRIX.md index bf1673f..24e6630 100644 --- a/docs/IMPLEMENTATION_MATRIX.md +++ b/docs/IMPLEMENTATION_MATRIX.md @@ -1,37 +1,30 @@ # Implementation matrix -This is a reality audit as of Phase 5 work. A feature is only **COMPLETE** when its database, -service, authorization, API, UI, error/empty states, and tests form a usable vertical slice. +This is a reality audit of the current VouchNet vertical slices. A feature is only **COMPLETE** +when its database, service, authorization, API, UI, error/empty states, and tests form a usable +path. Prefer this matrix over older Phase 5 notes when they disagree. -| Feature | Database | Repository | Domain service | Authorization | API | UI | Tests | Status | -| ------------------------------- | ---------------------- | ------------------ | ------------------- | ----------------------------------------- | ----------------------- | ------------------------------- | -------------------- | ------- | -| Email/password identity | partial | partial | partial | partial | partial | partial | partial | PARTIAL | -| Server sessions | partial | partial | partial | partial | partial | partial | partial | PARTIAL | -| Email verification | partial | partial | partial | partial | partial | local development only | partial | PARTIAL | -| Authenticated application shell | n/a | n/a | current-user query | server session check | n/a | protected routes | none | PARTIAL | -| Onboarding basics | partial | partial | partial | owner-only | partial | headline/location/about editor | none | PARTIAL | -| Professional profile read | partial | inline SQL | partial | public visibility/block checks | badge SVG read route | public `/in/[slug]` | none | PARTIAL | -| Professional profile editing | partial | partial | partial | owner-only + same-origin | PATCH `/api/profile/me` | onboarding editor | none | PARTIAL | -| Follow/unfollow | complete | inline SQL | partial | human session + block checks | partial | profile action | unit only | PARTIAL | -| Contact requests | complete | inline SQL | partial | human session + recipient-only acceptance | partial | profile/network actions | unit only | PARTIAL | -| Network management | partial | inline SQL | partial | partial | partial | invitations/counts/discovery | none | PARTIAL | -| People recommendations | partial | inline SQL | partial | block/visibility filters | partial | discovery screen | unit only | PARTIAL | -| People search | partial | PostgreSQL adapter | partial | visibility/block filters | none | `/search` | none | PARTIAL | -| Public organization directory | partial migration | inline SQL | public read | source-review only; no admin authority | n/a | `/company/[slug]` | MISSING | PARTIAL | -| Posts | partial migration | inline SQL | partial | human session + same-origin | partial | composer/cards | MISSING | PARTIAL | -| Feed | partial migration | inline SQL | partial | visibility/block filters | partial | chronological/signal modes | MISSING | PARTIAL | -| Reactions/comments/mentions | partial migration | inline SQL | reactions/mentions | human session + same-origin | reactions partial | reactions partial | MISSING | PARTIAL | -| Notifications | partial migration | inline SQL | aggregation partial | recipient-only query missing | write-side partial | honest unavailable state | MISSING | PARTIAL | -| Messaging | MISSING | MISSING | MISSING | MISSING | MISSING | honest unavailable state | MISSING | MISSING | -| Source-linked jobs | partial migration | inline SQL | public read | no internal application collection | n/a | public `/jobs` | MISSING | PARTIAL | -| Internal job applications | MISSING | MISSING | MISSING | MISSING | MISSING | MISSING | MISSING | MISSING | -| Projects | partial schema/tags | inline SQL | create/public read | owner-only write; public owner required | POST `/api/projects` | `/projects`, `/projects/[slug]` | MISSING | PARTIAL | -| Saved content | MISSING | MISSING | MISSING | MISSING | MISSING | honest unavailable state | MISSING | MISSING | -| Settings/security UI | partial session schema | partial | partial | partial | sessions API exists | partial | partial | PARTIAL | -| Privacy editor | partial schema | MISSING | partial policy | partial read enforcement | MISSING | MISSING | partial policy tests | MISSING | -| Volunteer moderation baseline | migration `0024` | inline SQL | role/report service | admin assignment; scoped reviewer queue | applications/reports | apply, report, queue, admin | MISSING | PARTIAL | -| MCP gateway/approval protocol | partial contracts | MISSING | partial contracts | partial contracts | MISSING | MISSING | partial unit tests | MISSING | -| Audit/trust/rate-limit runtime | partial schema | MISSING | partial contracts | MISSING | MISSING | MISSING | partial unit tests | MISSING | +| Feature | Database | Service / API | UI | Tests | Status | +| ----------------------------------- | -------------------- | ------------------------------------ | ------------------------------------------ | -------------- | ------- | +| Email/password identity | complete | register/login/verify/reset | login/signup/verify | partial | PARTIAL | +| Server sessions | complete | cookie sessions + idle expiration | settings sessions | partial | PARTIAL | +| MFA | complete (`0028`) | enroll/verify/disable | settings security | partial | PARTIAL | +| Professional profiles | complete | public read + owner edit | `/in`, `/vouch`, onboarding | partial | PARTIAL | +| Follow / contacts / network | complete | follow, connections, discover | network + discovery | unit only | PARTIAL | +| People search | complete | PostgreSQL search | `/search` | none | PARTIAL | +| Public organizations + jobs | complete | directory + employer intake | `/company`, `/jobs`, `/jobs/post` | partial | PARTIAL | +| Native job applications | complete (`0020`) | apply + candidate pipeline | apply + `/org/jobs/.../candidates` | partial | PARTIAL | +| Posts / feed / reactions | complete | feed, reactions, comments | `/feed` | partial | PARTIAL | +| Saved posts | complete (`0040`) | `/api/saved` | feed Save control + `/saved` | API unit | PARTIAL | +| Messaging | complete (`0026`) | browser + desktop device routes | `/messaging`, Desktop inbox | partial | PARTIAL | +| Notifications | complete | member notifications + mark-all-read | `/notifications` | partial | PARTIAL | +| Projects / Discover / opportunities | complete (`0029`) | build-in-public surfaces | `/projects`, `/discover`, `/opportunities` | partial | PARTIAL | +| Desktop client | device auth (`0027`) | feed, messages, activity | Tauri Windows client | package build | PARTIAL | +| Volunteer moderation | complete (`0024`) | apply/report/queue | moderation + admin | none | PARTIAL | +| Prepaid API credits | complete (`0029`) | Stripe Checkout + metering | account + developer center | inventory test | PARTIAL | +| Privacy / visibility settings | complete | settings APIs | `/settings/visibility` | partial | PARTIAL | +| MCP gateway / approval protocol | contracts only | not publicly live | developer docs only | unit contracts | MISSING | +| Audit / trust runtime | partial schema | partial contracts | admin/error telemetry only | partial | PARTIAL | -`MISSING` UI route entries are intentionally represented by explicit unavailable states where a -route is already part of the authenticated shell; they are not implemented product features. +`MISSING` means the product still withholds a fake UI. Partial means a usable path exists but +tests, edge coverage, or secondary clients remain incomplete. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7f818de..d6ac13f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -11,14 +11,12 @@ ## In progress: Phase 5 — actual application integration -Implement email/password signup and login, verified email architecture, Argon2id password hashing, rotating server-side sessions, password reset, profile creation/editing, session management, and centralized authorization. This wave will add the `users`, `profiles`, `sessions`, `email_verifications`, and `password_resets` schema with tested security invariants. - -The completed foundation is now being joined into real vertical slices. Current work has server-side -session guards, local-development signup/verification, profile basics, PostgreSQL search, follow, -Contact requests, discovery, public projects, a source-reviewed organization directory, -source-linked salary-transparent jobs, and embeddable SVG badges. Posts/feed, notifications, -messaging, organization administration, internal job applications, settings/privacy UI, production -email delivery, and E2E coverage remain incomplete. The matrix in +The foundation is joined into real vertical slices. Current work includes server-side session +guards, signup/verification, profiles, PostgreSQL search, follow and Contact requests, discovery, +public projects, organizations, salary-transparent jobs, posts/feed with reactions and comments, +messaging (web + Desktop), saved posts, notifications with mark-all-read, native job applications, +settings/privacy UI, moderation baselines, and prepaid API credits. Production email delivery and +broader E2E coverage remain incomplete. The matrix in [IMPLEMENTATION_MATRIX.md](IMPLEMENTATION_MATRIX.md) is the source of truth. ## In progress: transparent employer intake and provider sourcing diff --git a/packages/db/drizzle/0040_saved_posts.sql b/packages/db/drizzle/0040_saved_posts.sql new file mode 100644 index 0000000..30d7ddb --- /dev/null +++ b/packages/db/drizzle/0040_saved_posts.sql @@ -0,0 +1,10 @@ +-- Member-owned saved posts. Visibility and block rules are re-checked on every read so a save +-- cannot become a permanent bypass of the author's audience or a block relationship. +CREATE TABLE saved_posts ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + post_id uuid NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, post_id) +); +--> statement-breakpoint +CREATE INDEX saved_posts_user_created_idx ON saved_posts(user_id, created_at DESC); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index e791c2e..7db52b2 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -288,6 +288,13 @@ "when": 1792123200000, "tag": "0039_vouchnet_studio", "breakpoints": true + }, + { + "idx": 41, + "version": "7", + "when": 1792209600000, + "tag": "0040_saved_posts", + "breakpoints": true } ] }