From 4b3faea1e2efe96f5f204680181ce5766f18e96b Mon Sep 17 00:00:00 2001 From: Brayan Bedritchuk Date: Tue, 9 Jun 2026 15:37:16 -0300 Subject: [PATCH] feat(web): Add source ingestion workspace Add account-scoped pasted source contracts, API routes, server service and repository code, and a minimal source composer/list UI. Store pasted text as the initial account-scoped source chunk while list responses expose source metadata only. Update synthetic tests and evidence docs for validation, unauthenticated access, and cross-account behavior. Validation: - pnpm web:test - pnpm web:typecheck - pnpm web:lint - pnpm repo:test:fast --- apps/web/app/api/sources/[sourceId]/route.ts | 10 + apps/web/app/api/sources/route.ts | 12 + apps/web/app/globals.css | 180 +++++++++++++ apps/web/app/page.tsx | 11 +- .../features/home/components/HomeShell.tsx | 7 +- .../sources/components/SourcesWorkspace.tsx | 148 +++++++++++ apps/web/features/sources/constants.ts | 15 ++ apps/web/server/accounts/current.ts | 8 +- apps/web/server/sources/errors.ts | 22 ++ apps/web/server/sources/http.ts | 108 ++++++++ apps/web/server/sources/repository.ts | 164 ++++++++++++ apps/web/server/sources/service.ts | 97 +++++++ apps/web/server/sources/validation.ts | 60 +++++ apps/web/tests/sources.test.ts | 240 ++++++++++++++++++ docs/architecture/diagrams.md | 10 +- docs/architecture/system-overview.md | 8 +- docs/ops/cost-model.md | 4 +- docs/ops/failure-cases.md | 5 +- docs/roadmap/next-improvements.md | 6 +- docs/testing/testing-strategy.md | 10 +- packages/schemas/src/index.ts | 57 +++++ packages/shared-types/src/index.ts | 52 ++++ 22 files changed, 1210 insertions(+), 24 deletions(-) create mode 100644 apps/web/app/api/sources/[sourceId]/route.ts create mode 100644 apps/web/app/api/sources/route.ts create mode 100644 apps/web/features/sources/components/SourcesWorkspace.tsx create mode 100644 apps/web/features/sources/constants.ts create mode 100644 apps/web/server/sources/errors.ts create mode 100644 apps/web/server/sources/http.ts create mode 100644 apps/web/server/sources/repository.ts create mode 100644 apps/web/server/sources/service.ts create mode 100644 apps/web/server/sources/validation.ts create mode 100644 apps/web/tests/sources.test.ts diff --git a/apps/web/app/api/sources/[sourceId]/route.ts b/apps/web/app/api/sources/[sourceId]/route.ts new file mode 100644 index 0000000..35ab4fe --- /dev/null +++ b/apps/web/app/api/sources/[sourceId]/route.ts @@ -0,0 +1,10 @@ +import { requireCurrentAccountScope } from "@/server/accounts/current"; +import { createSourceDetailRouteHandlers } from "@/server/sources/http"; +import { readPastedSource } from "@/server/sources/service"; + +const handlers = createSourceDetailRouteHandlers({ + requireAccountScope: requireCurrentAccountScope, + readPastedSource, +}); + +export const GET = handlers.GET; diff --git a/apps/web/app/api/sources/route.ts b/apps/web/app/api/sources/route.ts new file mode 100644 index 0000000..3d26296 --- /dev/null +++ b/apps/web/app/api/sources/route.ts @@ -0,0 +1,12 @@ +import { requireCurrentAccountScope } from "@/server/accounts/current"; +import { createSourcesRouteHandlers } from "@/server/sources/http"; +import { createPastedSource, listPastedSources } from "@/server/sources/service"; + +const handlers = createSourcesRouteHandlers({ + requireAccountScope: requireCurrentAccountScope, + createPastedSource, + listPastedSources, +}); + +export const GET = handlers.GET; +export const POST = handlers.POST; diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 0eae0a5..d52a85d 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -211,6 +211,164 @@ h1 { padding: 10px 12px; } +.sourceWorkspace { + display: grid; + gap: 16px; + grid-template-columns: minmax(0, 1.05fr) minmax(280px, 0.95fr); + margin: 0 auto 24px; + max-width: 1120px; +} + +.sourceComposer, +.sourceListPanel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 18px; +} + +.sourceComposer { + display: grid; + gap: 14px; +} + +.sourceComposer h2, +.sourceListHeader h2 { + font-size: 1.25rem; + line-height: 1.25; + margin-bottom: 0; +} + +.sourceComposer label { + display: grid; + font-size: 0.9rem; + font-weight: 700; + gap: 6px; +} + +.sourceComposer input, +.sourceComposer textarea { + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + font: inherit; + padding: 10px 12px; + width: 100%; +} + +.sourceComposer input { + min-height: 42px; +} + +.sourceComposer textarea { + line-height: 1.5; + min-height: 220px; + resize: vertical; +} + +.sourceComposer input:disabled, +.sourceComposer textarea:disabled { + background: #f2f4f1; + cursor: not-allowed; +} + +.sourceComposerFooter, +.sourceListHeader { + align-items: center; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.sourceComposerFooter span { + color: var(--muted); + font-size: 0.82rem; +} + +.sourceListPanel { + align-content: start; + display: grid; + gap: 14px; + min-height: 320px; +} + +.sourceListHeader strong { + align-items: center; + background: #eaf7f4; + border: 1px solid #b7dcd3; + border-radius: 999px; + color: var(--teal); + display: inline-flex; + font-size: 0.9rem; + justify-content: center; + min-height: 34px; + min-width: 40px; + padding: 0 12px; +} + +.sourceState { + align-self: start; + color: var(--muted); + font-size: 0.92rem; + line-height: 1.5; + margin: 0; +} + +.sourceList { + display: grid; + gap: 0; + list-style: none; + margin: 0; + padding: 0; +} + +.sourceList li { + align-items: start; + border-top: 1px solid var(--line); + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr) auto; + padding: 14px 0; +} + +.sourceList li:first-child { + border-top: 0; + padding-top: 0; +} + +.sourceList h3 { + font-size: 0.98rem; + line-height: 1.3; + margin: 0 0 4px; + overflow-wrap: anywhere; +} + +.sourceList p { + color: var(--muted); + font-size: 0.82rem; + margin: 0; +} + +.sourceList dl { + display: grid; + gap: 8px; + margin: 0; + min-width: 112px; +} + +.sourceList dt { + color: var(--muted); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; +} + +.sourceList dd { + font-size: 0.9rem; + font-weight: 700; + margin: 2px 0 0; +} + .authSwitch { color: var(--muted); font-size: 0.9rem; @@ -291,6 +449,10 @@ h1 { .workspaceGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .sourceWorkspace { + grid-template-columns: 1fr; + } } @media (max-width: 560px) { @@ -305,4 +467,22 @@ h1 { .workspaceGrid { grid-template-columns: 1fr; } + + .sourceComposerFooter, + .sourceList li { + align-items: stretch; + grid-template-columns: 1fr; + } + + .sourceComposerFooter { + flex-direction: column; + } + + .sourceComposerFooter .buttonLink { + width: 100%; + } + + .sourceList dl { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 7eb0bf7..0b4f1f9 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,16 +1,21 @@ import { HomeShell } from "@/features/home/components/HomeShell"; import { auth } from "@/server/auth"; +import { getAccountScopeFromSessionValue } from "@/server/accounts/current"; +import { listPastedSources } from "@/server/sources/service"; export default async function Page() { const session = await auth(); + const scope = await getAccountScopeFromSessionValue(session); + const sources = scope ? await listPastedSources(scope) : []; return ( ; -export function HomeShell({ user }: HomeShellProps) { +export function HomeShell({ user, initialSources }: HomeShellProps) { return (
@@ -30,6 +33,8 @@ export function HomeShell({ user }: HomeShellProps) {
+ + {homeLanes.map((lane) => ( diff --git a/apps/web/features/sources/components/SourcesWorkspace.tsx b/apps/web/features/sources/components/SourcesWorkspace.tsx new file mode 100644 index 0000000..56fa93e --- /dev/null +++ b/apps/web/features/sources/components/SourcesWorkspace.tsx @@ -0,0 +1,148 @@ +"use client"; + +import type { + CreatePastedSourceResponse, + SourceSummary, +} from "@ancora/shared-types"; +import { FormEvent, useState, useTransition } from "react"; +import { + sourceErrorMessages, + sourcesEndpoint, + sourceTextMaxLength, +} from "@/features/sources/constants"; + +type SourcesWorkspaceProps = Readonly<{ + initialSources: readonly SourceSummary[]; + isAuthenticated: boolean; +}>; + +export function SourcesWorkspace({ initialSources, isAuthenticated }: SourcesWorkspaceProps) { + const [sources, setSources] = useState(initialSources); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + function onSubmit(event: FormEvent) { + event.preventDefault(); + + if (!isAuthenticated) { + setError(sourceErrorMessages.unauthorized); + return; + } + + const form = event.currentTarget; + const formData = new FormData(form); + setError(null); + + startTransition(async () => { + const response = await fetch(sourcesEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: formData.get("title"), + text: formData.get("text"), + }), + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { error?: string } | null; + setError(sourceErrorMessages[payload?.error ?? ""] ?? "Unable to create source."); + return; + } + + const payload = (await response.json()) as CreatePastedSourceResponse; + + setSources((currentSources) => [payload.source, ...currentSources]); + form.reset(); + }); + } + + return ( +
+
+
+

Sources

+

Paste learning material

+
+ +