From f4549a4a58e77d081dbb05bbe84bd78953114ec7 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 2 May 2026 21:17:25 +0300 Subject: [PATCH 1/2] Fix model field name --- ..._entitycandidate_auto_promotion_blocked_reason_and_more.py | 4 ++-- entities/models.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/entities/migrations/0003_entitycandidate_auto_promotion_blocked_reason_and_more.py b/entities/migrations/0003_entitycandidate_auto_promotion_blocked_reason_and_more.py index 23709aee..7bc30aec 100644 --- a/entities/migrations/0003_entitycandidate_auto_promotion_blocked_reason_and_more.py +++ b/entities/migrations/0003_entitycandidate_auto_promotion_blocked_reason_and_more.py @@ -89,11 +89,11 @@ class Migration(migrations.Migration): "indexes": [ models.Index( fields=["candidate", "source_plugin"], - name="core_entitycand_candidate_source_idx", + name="core_entcand_candsrc_idx", ), models.Index( fields=["project", "created_at"], - name="core_entitycand_project_created_idx", + name="core_entcand_projtime_idx", ), ], "constraints": [ diff --git a/entities/models.py b/entities/models.py index 9e4cf87a..86d8385f 100644 --- a/entities/models.py +++ b/entities/models.py @@ -290,11 +290,11 @@ class Meta: indexes = [ models.Index( fields=["candidate", "source_plugin"], - name="core_entitycand_candidate_source_idx", + name="core_entcand_candsrc_idx", ), models.Index( fields=["project", "created_at"], - name="core_entitycand_project_created_idx", + name="core_entcand_projtime_idx", ), ] From 43d965eded19ad9d09276cb9d2425bbd922c5b87 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 3 May 2026 02:25:27 +0300 Subject: [PATCH 2/2] Add frontend work --- core/tests/test_entrypoints.py | 4 + entities/api.py | 2 +- entities/serializers.py | 48 ++ entities/tests/test_api.py | 14 + frontend/src/app/admin/sources/page.test.tsx | 37 + frontend/src/app/admin/sources/page.tsx | 81 +++ .../api/entity-candidates/[id]/route.test.ts | 6 + .../[id]/authority-settings/route.test.ts | 97 +++ .../projects/[id]/authority-settings/route.ts | 109 +++ .../api/projects/[id]/draft-action-helpers.ts | 22 + .../[id]/draft-items/[itemId]/route.test.ts | 98 +++ .../[id]/draft-items/[itemId]/route.ts | 95 +++ .../[pieceId]/route.test.ts | 113 +++ .../draft-original-pieces/[pieceId]/route.ts | 98 +++ .../draft-sections/[sectionId]/route.test.ts | 97 +++ .../[id]/draft-sections/[sectionId]/route.ts | 95 +++ .../regenerate-section/route.test.ts | 111 +++ .../[draftId]/regenerate-section/route.ts | 48 ++ .../[id]/drafts/[draftId]/route.test.ts | 96 +++ .../projects/[id]/drafts/[draftId]/route.ts | 46 ++ .../[id]/drafts/generate/route.test.ts | 100 +++ .../projects/[id]/drafts/generate/route.ts | 42 ++ .../[id]/entity-candidate-bulk/route.test.ts | 106 +++ .../[id]/entity-candidate-bulk/route.ts | 90 +++ .../linkedin-source-configs/route.test.ts | 67 ++ .../[id]/linkedin-source-configs/route.ts | 71 ++ .../_components/DraftEditor/index.test.tsx | 230 ++++++ .../_components/DraftEditor/index.tsx | 643 +++++++++++++++++ .../src/app/drafts/[draftId]/page.test.tsx | 195 +++++ frontend/src/app/drafts/[draftId]/page.tsx | 171 +++++ frontend/src/app/drafts/page.test.tsx | 136 ++++ frontend/src/app/drafts/page.tsx | 200 ++++++ .../AuthorityWeightControls/index.test.tsx | 101 +++ .../AuthorityWeightControls/index.tsx | 239 +++++++ frontend/src/app/entities/[id]/page.test.tsx | 109 ++- frontend/src/app/entities/[id]/page.tsx | 158 +++- .../src/app/entities/candidates/page.test.tsx | 188 +++++ frontend/src/app/entities/candidates/page.tsx | 307 ++++++++ frontend/src/app/entities/page.test.tsx | 9 + frontend/src/app/entities/page.tsx | 9 + .../components/layout/AppShell/index.test.tsx | 4 + .../src/components/layout/AppShell/index.tsx | 6 + frontend/src/lib/api.ts | 345 +++++++++ frontend/src/lib/types.ts | 156 ++++ frontend/tsconfig.tsbuildinfo | 2 +- newsletter_maker/settings/celery.py | 4 + newsletters/api.py | 320 ++++++++- newsletters/api_urls.py | 29 +- newsletters/composition.py | 674 ++++++++++++++++++ ...0002_newsletterdraft_and_related_models.py | 202 ++++++ newsletters/models.py | 203 ++++++ newsletters/serializers.py | 182 ++++- newsletters/tasks.py | 129 +++- newsletters/tests/test_api.py | 251 +++++++ newsletters/tests/test_tasks.py | 352 +++++++++ projects/admin.py | 2 + projects/api.py | 62 +- .../0009_projectconfig_draft_schedule_cron.py | 15 + projects/models.py | 1 + projects/serializers.py | 16 + projects/tests/test_admin.py | 4 + projects/tests/test_api.py | 61 +- skills/newsletter_composition/SKILL.md | 15 + .../resources/coherence_pass.md | 7 + .../resources/intro_outro_composer.md | 8 + .../resources/section_composer.md | 9 + 66 files changed, 7525 insertions(+), 22 deletions(-) create mode 100644 frontend/src/app/api/projects/[id]/authority-settings/route.test.ts create mode 100644 frontend/src/app/api/projects/[id]/authority-settings/route.ts create mode 100644 frontend/src/app/api/projects/[id]/draft-action-helpers.ts create mode 100644 frontend/src/app/api/projects/[id]/draft-items/[itemId]/route.test.ts create mode 100644 frontend/src/app/api/projects/[id]/draft-items/[itemId]/route.ts create mode 100644 frontend/src/app/api/projects/[id]/draft-original-pieces/[pieceId]/route.test.ts create mode 100644 frontend/src/app/api/projects/[id]/draft-original-pieces/[pieceId]/route.ts create mode 100644 frontend/src/app/api/projects/[id]/draft-sections/[sectionId]/route.test.ts create mode 100644 frontend/src/app/api/projects/[id]/draft-sections/[sectionId]/route.ts create mode 100644 frontend/src/app/api/projects/[id]/drafts/[draftId]/regenerate-section/route.test.ts create mode 100644 frontend/src/app/api/projects/[id]/drafts/[draftId]/regenerate-section/route.ts create mode 100644 frontend/src/app/api/projects/[id]/drafts/[draftId]/route.test.ts create mode 100644 frontend/src/app/api/projects/[id]/drafts/[draftId]/route.ts create mode 100644 frontend/src/app/api/projects/[id]/drafts/generate/route.test.ts create mode 100644 frontend/src/app/api/projects/[id]/drafts/generate/route.ts create mode 100644 frontend/src/app/api/projects/[id]/entity-candidate-bulk/route.test.ts create mode 100644 frontend/src/app/api/projects/[id]/entity-candidate-bulk/route.ts create mode 100644 frontend/src/app/api/projects/[id]/linkedin-source-configs/route.test.ts create mode 100644 frontend/src/app/api/projects/[id]/linkedin-source-configs/route.ts create mode 100644 frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.test.tsx create mode 100644 frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.tsx create mode 100644 frontend/src/app/drafts/[draftId]/page.test.tsx create mode 100644 frontend/src/app/drafts/[draftId]/page.tsx create mode 100644 frontend/src/app/drafts/page.test.tsx create mode 100644 frontend/src/app/drafts/page.tsx create mode 100644 frontend/src/app/entities/[id]/_components/AuthorityWeightControls/index.test.tsx create mode 100644 frontend/src/app/entities/[id]/_components/AuthorityWeightControls/index.tsx create mode 100644 frontend/src/app/entities/candidates/page.test.tsx create mode 100644 frontend/src/app/entities/candidates/page.tsx create mode 100644 newsletters/composition.py create mode 100644 newsletters/migrations/0002_newsletterdraft_and_related_models.py create mode 100644 newsletters/tests/test_tasks.py create mode 100644 projects/migrations/0009_projectconfig_draft_schedule_cron.py create mode 100644 skills/newsletter_composition/SKILL.md create mode 100644 skills/newsletter_composition/resources/coherence_pass.md create mode 100644 skills/newsletter_composition/resources/intro_outro_composer.md create mode 100644 skills/newsletter_composition/resources/section_composer.md diff --git a/core/tests/test_entrypoints.py b/core/tests/test_entrypoints.py index d2f14049..52560ed5 100644 --- a/core/tests/test_entrypoints.py +++ b/core/tests/test_entrypoints.py @@ -51,6 +51,10 @@ def test_celery_app_schedules_source_quality_before_authority_recompute(): beat_schedule["run-all-source-quality-recomputations-nightly"]["task"] == "core.tasks.run_all_source_quality_recomputations" ) + assert ( + beat_schedule["run-all-scheduled-newsletter-drafts-every-minute"]["task"] + == "core.tasks.run_all_scheduled_newsletter_drafts" + ) assert ( beat_schedule["run-all-authority-recomputations-nightly"]["task"] == "core.tasks.run_all_authority_recomputations" diff --git a/entities/api.py b/entities/api.py index 5de47b5e..1c30277f 100644 --- a/entities/api.py +++ b/entities/api.py @@ -180,7 +180,7 @@ class EntityCandidateViewSet(ProjectOwnedQuerysetMixin, viewsets.ReadOnlyModelVi serializer_class = EntityCandidateSerializer queryset = EntityCandidate.objects.select_related( "project", "first_seen_in", "merged_into" - ) + ).prefetch_related("evidence") def get_permissions(self): """Allow all members to read candidates and contributors to resolve them.""" diff --git a/entities/serializers.py b/entities/serializers.py index a461617f..96a7226a 100644 --- a/entities/serializers.py +++ b/entities/serializers.py @@ -120,6 +120,10 @@ class EntityCandidateSerializer( source="first_seen_in.title", read_only=True ) merged_into_name = serializers.CharField(source="merged_into.name", read_only=True) + evidence_count = serializers.SerializerMethodField() + source_plugin_count = serializers.SerializerMethodField() + source_plugins = serializers.SerializerMethodField() + identity_surfaces = serializers.SerializerMethodField() class Meta: model = EntityCandidate @@ -133,6 +137,10 @@ class Meta: "occurrence_count", "cluster_key", "auto_promotion_blocked_reason", + "evidence_count", + "source_plugin_count", + "source_plugins", + "identity_surfaces", "status", "merged_into", "merged_into_name", @@ -141,6 +149,46 @@ class Meta: ] read_only_fields = fields + def _candidate_evidence(self, obj): + """Return prefetched evidence rows when available.""" + + evidence = getattr(obj, "prefetched_evidence", None) + if evidence is None: + evidence = obj.evidence.all() + return evidence + + def get_evidence_count(self, obj) -> int: + """Return the number of evidence rows attached to the candidate.""" + + return len(self._candidate_evidence(obj)) + + def get_source_plugin_count(self, obj) -> int: + """Return the number of unique source plugins backing the candidate.""" + + return len(self.get_source_plugins(obj)) + + def get_source_plugins(self, obj) -> list[str]: + """Return the unique source plugins seen in candidate evidence.""" + + return sorted( + { + evidence.source_plugin + for evidence in self._candidate_evidence(obj) + if evidence.source_plugin + } + ) + + def get_identity_surfaces(self, obj) -> list[str]: + """Return the unique identity surfaces hinted by candidate evidence.""" + + return sorted( + { + evidence.identity_surface + for evidence in self._candidate_evidence(obj) + if evidence.identity_surface + } + ) + class EntityCandidateMergeSerializer( ProjectScopedSerializerMixin, serializers.Serializer diff --git a/entities/tests/test_api.py b/entities/tests/test_api.py index 1a83f90b..e18ecbe9 100644 --- a/entities/tests/test_api.py +++ b/entities/tests/test_api.py @@ -12,6 +12,7 @@ Entity, EntityAuthoritySnapshot, EntityCandidate, + EntityCandidateEvidence, EntityCandidateStatus, EntityIdentityClaim, EntityMention, @@ -333,6 +334,15 @@ def test_entity_candidate_list_is_scoped_to_request_user_project(self): cluster_key="owner-candidate-abcd1234", auto_promotion_blocked_reason="needs_more_occurrences", ) + EntityCandidateEvidence.objects.create( + candidate=owner_candidate, + project=self.owner_project, + content=self.owner_content, + source_plugin="linkedin", + context_excerpt="Owner Candidate was cited in the article.", + identity_surface="linkedin", + claim_url="https://www.linkedin.com/company/owner-candidate", + ) EntityCandidate.objects.create( project=self.other_project, name="Other Candidate", @@ -355,6 +365,10 @@ def test_entity_candidate_list_is_scoped_to_request_user_project(self): response.json()[0]["auto_promotion_blocked_reason"], owner_candidate.auto_promotion_blocked_reason, ) + self.assertEqual(response.json()[0]["evidence_count"], 1) + self.assertEqual(response.json()[0]["source_plugin_count"], 1) + self.assertEqual(response.json()[0]["source_plugins"], ["linkedin"]) + self.assertEqual(response.json()[0]["identity_surfaces"], ["linkedin"]) def test_entity_candidate_accept_action_returns_updated_candidate(self): with patch("entities.extraction.queue_entity_identity_enrichment"): diff --git a/frontend/src/app/admin/sources/page.test.tsx b/frontend/src/app/admin/sources/page.test.tsx index 90506709..2a28af37 100644 --- a/frontend/src/app/admin/sources/page.test.tsx +++ b/frontend/src/app/admin/sources/page.test.tsx @@ -280,6 +280,43 @@ describe("filterNewsletterIntakes", () => { }) }) +describe("SourcesPage", () => { + beforeEach(() => { + const defaultProject = createProject() + + getProjectsMock.mockReset() + getProjectSourceConfigsMock.mockReset() + getProjectIngestionRunsMock.mockReset() + getProjectIntakeAllowlistMock.mockReset() + getProjectNewsletterIntakesMock.mockReset() + getProjectBlueskyCredentialsMock.mockReset() + getProjectLinkedInCredentialsMock.mockReset() + getProjectMastodonCredentialsMock.mockReset() + selectProjectMock.mockReset() + + getProjectsMock.mockResolvedValue([defaultProject]) + getProjectSourceConfigsMock.mockResolvedValue([createSourceConfig()]) + getProjectIngestionRunsMock.mockResolvedValue([createIngestionRun()]) + getProjectIntakeAllowlistMock.mockResolvedValue([createAllowlistEntry()]) + getProjectNewsletterIntakesMock.mockResolvedValue([createNewsletterIntake()]) + getProjectBlueskyCredentialsMock.mockResolvedValue([createBlueskyCredentials()]) + getProjectLinkedInCredentialsMock.mockResolvedValue([ + createLinkedInCredentials({ expires_at: "2026-05-30T10:00:00Z" }), + ]) + getProjectMastodonCredentialsMock.mockResolvedValue([createMastodonCredentials()]) + selectProjectMock.mockImplementation((projects: Project[]) => projects[0] ?? null) + }) + + it("renders the LinkedIn quick-add form alongside the OAuth panel", async () => { + await renderSourcesPage({ project: "1" }) + + expect(screen.getByText("OAuth authorization")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Add LinkedIn source" })).toBeInTheDocument() + expect(screen.getByText("Surface type")).toBeInTheDocument() + expect(screen.getByText("Quick config shapes")).toBeInTheDocument() + }) +}) + describe("SourcesPage", () => { beforeEach(() => { const defaultProject = createProject() diff --git a/frontend/src/app/admin/sources/page.tsx b/frontend/src/app/admin/sources/page.tsx index f19570e8..c1c03b8f 100644 --- a/frontend/src/app/admin/sources/page.tsx +++ b/frontend/src/app/admin/sources/page.tsx @@ -830,6 +830,87 @@ export default async function SourcesPage({ searchParams }: SourcesPageProps) { Verify LinkedIn credentials + +
+
+ +
+

+ Add LinkedIn source +

+

+ Create a project-scoped LinkedIn source without hand-writing config JSON. +

+
+ + +
+ + +
+ +
+ +
+

+ Quick config shapes +

+

+ Organization and newsletter sources use max_posts_per_fetch. Person sources use include_reshares. +

+

+ The generic source editor below still works for advanced payloads, but most projects should be able to onboard LinkedIn surfaces from this form alone. +

+
+
diff --git a/frontend/src/app/api/entity-candidates/[id]/route.test.ts b/frontend/src/app/api/entity-candidates/[id]/route.test.ts index 5a4e4ab7..cfeb0078 100644 --- a/frontend/src/app/api/entity-candidates/[id]/route.test.ts +++ b/frontend/src/app/api/entity-candidates/[id]/route.test.ts @@ -35,6 +35,12 @@ function createCandidate(overrides: Partial = {}): EntityCandid first_seen_in: 21, first_seen_title: "River Labs launches hosted platform", occurrence_count: 2, + cluster_key: "cluster-9", + auto_promotion_blocked_reason: "needs_more_occurrences", + evidence_count: 2, + source_plugin_count: 2, + source_plugins: ["linkedin", "rss"], + identity_surfaces: ["linkedin"], status: "pending", merged_into: null, merged_into_name: "", diff --git a/frontend/src/app/api/projects/[id]/authority-settings/route.test.ts b/frontend/src/app/api/projects/[id]/authority-settings/route.test.ts new file mode 100644 index 00000000..7255fdcc --- /dev/null +++ b/frontend/src/app/api/projects/[id]/authority-settings/route.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + createProjectConfig, + recomputeProjectConfigAuthority, + updateProjectConfig, +} from "@/lib/api" + +import { POST } from "./route" + +vi.mock("@/lib/api", () => ({ + createProjectConfig: vi.fn(), + recomputeProjectConfigAuthority: vi.fn(), + updateProjectConfig: vi.fn(), +})) + +describe("POST /api/projects/[id]/authority-settings", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function buildFormData() { + const formData = new FormData() + formData.set("draft_schedule_cron", "") + formData.set("authority_weight_mention", "0.2") + formData.set("authority_weight_engagement", "0.15") + formData.set("authority_weight_recency", "0.15") + formData.set("authority_weight_source_quality", "0.15") + formData.set("authority_weight_cross_newsletter", "0.2") + formData.set("authority_weight_feedback", "0.1") + formData.set("authority_weight_duplicate", "0.05") + formData.set("upvote_authority_weight", "0.05") + formData.set("downvote_authority_weight", "-0.05") + formData.set("authority_decay_rate", "0.9") + return formData + } + + it("creates a config and returns JSON for a save request", async () => { + vi.mocked(createProjectConfig).mockResolvedValue({ id: 7 } as never) + + const response = await POST( + new Request("http://localhost/api/projects/4/authority-settings?mode=json", { + method: "POST", + body: buildFormData(), + }), + { + params: Promise.resolve({ id: "4" }), + }, + ) + + expect(createProjectConfig).toHaveBeenCalledWith( + 4, + expect.objectContaining({ authority_weight_engagement: 0.15 }), + ) + expect(updateProjectConfig).not.toHaveBeenCalled() + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + configId: 7, + message: "Authority weights saved.", + }) + }) + + it("updates and recomputes when requested", async () => { + vi.mocked(updateProjectConfig).mockResolvedValue({ id: 9 } as never) + vi.mocked(recomputeProjectConfigAuthority).mockResolvedValue({ + status: "completed", + project_id: 4, + config_id: 9, + }) + + const formData = buildFormData() + formData.set("configId", "9") + formData.set("intent", "save_and_recompute") + + const response = await POST( + new Request("http://localhost/api/projects/4/authority-settings?mode=json", { + method: "POST", + body: formData, + }), + { + params: Promise.resolve({ id: "4" }), + }, + ) + + expect(updateProjectConfig).toHaveBeenCalledWith( + 4, + 9, + expect.objectContaining({ authority_weight_source_quality: 0.15 }), + ) + expect(recomputeProjectConfigAuthority).toHaveBeenCalledWith(4, 9) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + configId: 9, + message: "Authority weights saved and recomputed.", + }) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/authority-settings/route.ts b/frontend/src/app/api/projects/[id]/authority-settings/route.ts new file mode 100644 index 00000000..2b872ae3 --- /dev/null +++ b/frontend/src/app/api/projects/[id]/authority-settings/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from "next/server" + +import { + createProjectConfig, + recomputeProjectConfigAuthority, + updateProjectConfig, +} from "@/lib/api" + +type AuthorityWeightsPayload = { + draft_schedule_cron: string + authority_weight_mention: number + authority_weight_engagement: number + authority_weight_recency: number + authority_weight_source_quality: number + authority_weight_cross_newsletter: number + authority_weight_feedback: number + authority_weight_duplicate: number + upvote_authority_weight: number + downvote_authority_weight: number + authority_decay_rate: number +} + +function buildRedirectUrl( + request: Request, + redirectTo: string, + params: Record, +) { + const url = new URL(redirectTo || "/", request.url) + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + return url +} + +function parseNumericField(formData: FormData, fieldName: keyof AuthorityWeightsPayload) { + return Number.parseFloat(String(formData.get(fieldName) || "0")) +} + +function extractPayload(formData: FormData): AuthorityWeightsPayload { + return { + draft_schedule_cron: String(formData.get("draft_schedule_cron") || ""), + authority_weight_mention: parseNumericField(formData, "authority_weight_mention"), + authority_weight_engagement: parseNumericField(formData, "authority_weight_engagement"), + authority_weight_recency: parseNumericField(formData, "authority_weight_recency"), + authority_weight_source_quality: parseNumericField(formData, "authority_weight_source_quality"), + authority_weight_cross_newsletter: parseNumericField(formData, "authority_weight_cross_newsletter"), + authority_weight_feedback: parseNumericField(formData, "authority_weight_feedback"), + authority_weight_duplicate: parseNumericField(formData, "authority_weight_duplicate"), + upvote_authority_weight: parseNumericField(formData, "upvote_authority_weight"), + downvote_authority_weight: parseNumericField(formData, "downvote_authority_weight"), + authority_decay_rate: parseNumericField(formData, "authority_decay_rate"), + } +} + +/** + * Handle authority-weight save and recompute requests for one project. + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + const { id } = await context.params + const responseMode = new URL(request.url).searchParams.get("mode") + const formData = await request.formData() + const redirectTo = String(formData.get("redirectTo") || `/entities?project=${id}`) + + try { + const projectId = Number.parseInt(id, 10) + const configId = Number.parseInt(String(formData.get("configId") || "0"), 10) + const intent = String(formData.get("intent") || "save") + const payload = extractPayload(formData) + + const config = + configId > 0 + ? await updateProjectConfig(projectId, configId, payload) + : await createProjectConfig(projectId, payload) + + let message = "Authority weights saved." + + if (intent === "save_and_recompute") { + const recompute = await recomputeProjectConfigAuthority(projectId, config.id) + message = + recompute.status === "completed" + ? "Authority weights saved and recomputed." + : "Authority weights saved and recompute queued." + } + + if (responseMode === "json") { + return NextResponse.json({ configId: config.id, message }) + } + + return NextResponse.redirect( + buildRedirectUrl(request, redirectTo, { message }), + ) + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Unable to save authority settings." + + if (responseMode === "json") { + return NextResponse.json({ message }, { status: 400 }) + } + + return NextResponse.redirect( + buildRedirectUrl(request, redirectTo, { error: message }), + ) + } +} \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/draft-action-helpers.ts b/frontend/src/app/api/projects/[id]/draft-action-helpers.ts new file mode 100644 index 00000000..7d6469d4 --- /dev/null +++ b/frontend/src/app/api/projects/[id]/draft-action-helpers.ts @@ -0,0 +1,22 @@ +/** + * Build a redirect target for project-scoped draft workflow handlers. + * + * Relative redirects are resolved against the incoming request URL so route handlers can + * safely accept short page paths like `/drafts?project=4` from form submissions. + * + * @param request - Incoming request used as the base URL for relative redirects. + * @param redirectTo - Caller-provided redirect target, or a fallback page path. + * @param params - Query params to append to the redirect target. + * @returns A redirect URL with the requested flash-message params appended. + */ +export function buildDraftRedirectUrl( + request: Request, + redirectTo: string, + params: Record, +) { + const url = new URL(redirectTo, request.url) + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + return url +} \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/draft-items/[itemId]/route.test.ts b/frontend/src/app/api/projects/[id]/draft-items/[itemId]/route.test.ts new file mode 100644 index 00000000..1d185f87 --- /dev/null +++ b/frontend/src/app/api/projects/[id]/draft-items/[itemId]/route.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + deleteProjectNewsletterDraftItem, + updateProjectNewsletterDraftItem, +} from "@/lib/api" + +import { POST } from "./route" + +vi.mock("@/lib/api", () => ({ + deleteProjectNewsletterDraftItem: vi.fn(), + updateProjectNewsletterDraftItem: vi.fn(), +})) + +describe("POST /api/projects/[id]/draft-items/[itemId]", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns JSON for draft-item updates", async () => { + vi.mocked(updateProjectNewsletterDraftItem).mockResolvedValue(undefined as never) + + const formData = new FormData() + formData.set("summary_used", "Updated summary") + formData.set("why_it_matters", "Updated why") + + const response = await POST( + new Request("http://localhost/api/projects/4/draft-items/44?mode=json", { + method: "POST", + body: formData, + }), + { + params: Promise.resolve({ id: "4", itemId: "44" }), + }, + ) + + expect(updateProjectNewsletterDraftItem).toHaveBeenCalledWith(44, 4, { + summary_used: "Updated summary", + why_it_matters: "Updated why", + }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ message: "Draft item updated." }) + }) + + it("swaps item order when moving an item", async () => { + vi.mocked(updateProjectNewsletterDraftItem).mockResolvedValue(undefined as never) + + const formData = new FormData() + formData.set("intent", "move_down") + formData.set("currentOrder", "0") + formData.set("targetOrder", "1") + formData.set("swapWithId", "45") + + const response = await POST( + new Request("http://localhost/api/projects/4/draft-items/44?mode=json", { + method: "POST", + body: formData, + }), + { + params: Promise.resolve({ id: "4", itemId: "44" }), + }, + ) + + expect(updateProjectNewsletterDraftItem).toHaveBeenNthCalledWith(1, 44, 4, { + order: -1, + }) + expect(updateProjectNewsletterDraftItem).toHaveBeenNthCalledWith(2, 45, 4, { + order: 0, + }) + expect(updateProjectNewsletterDraftItem).toHaveBeenNthCalledWith(3, 44, 4, { + order: 1, + }) + await expect(response.json()).resolves.toEqual({ + message: "Draft item moved down.", + }) + }) + + it("deletes a draft item when requested", async () => { + vi.mocked(deleteProjectNewsletterDraftItem).mockResolvedValue(undefined) + + const formData = new FormData() + formData.set("intent", "delete") + + const response = await POST( + new Request("http://localhost/api/projects/4/draft-items/44?mode=json", { + method: "POST", + body: formData, + }), + { + params: Promise.resolve({ id: "4", itemId: "44" }), + }, + ) + + expect(deleteProjectNewsletterDraftItem).toHaveBeenCalledWith(44, 4) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ message: "Draft item removed." }) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/draft-items/[itemId]/route.ts b/frontend/src/app/api/projects/[id]/draft-items/[itemId]/route.ts new file mode 100644 index 00000000..70d42b45 --- /dev/null +++ b/frontend/src/app/api/projects/[id]/draft-items/[itemId]/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server" + +import { + deleteProjectNewsletterDraftItem, + updateProjectNewsletterDraftItem, +} from "@/lib/api" + +import { buildDraftRedirectUrl } from "../../draft-action-helpers" + +async function swapItemOrder( + projectId: number, + itemId: number, + currentOrder: number, + targetOrder: number, + swapWithId: number, +) { + await updateProjectNewsletterDraftItem(itemId, projectId, { order: -1 }) + await updateProjectNewsletterDraftItem(swapWithId, projectId, { + order: currentOrder, + }) + await updateProjectNewsletterDraftItem(itemId, projectId, { + order: targetOrder, + }) +} + +/** + * Handle inline draft-item updates, deletes, and reordering actions. + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string; itemId: string }> }, +) { + const { id, itemId } = await context.params + const responseMode = new URL(request.url).searchParams.get("mode") + const formData = await request.formData() + const redirectTo = String(formData.get("redirectTo") || `/drafts?project=${id}`) + + try { + const projectId = Number.parseInt(id, 10) + const resolvedItemId = Number.parseInt(itemId, 10) + const intent = String(formData.get("intent") || "update") + let message = "Draft item updated." + + if (intent === "delete") { + await deleteProjectNewsletterDraftItem(resolvedItemId, projectId) + message = "Draft item removed." + } else if (intent === "move_up" || intent === "move_down") { + const currentOrder = Number.parseInt( + String(formData.get("currentOrder") || "0"), + 10, + ) + const targetOrder = Number.parseInt( + String(formData.get("targetOrder") || String(currentOrder)), + 10, + ) + const swapWithId = Number.parseInt( + String(formData.get("swapWithId") || "0"), + 10, + ) + + await swapItemOrder( + projectId, + resolvedItemId, + currentOrder, + targetOrder, + swapWithId, + ) + message = intent === "move_up" ? "Draft item moved up." : "Draft item moved down." + } else { + await updateProjectNewsletterDraftItem(resolvedItemId, projectId, { + summary_used: String(formData.get("summary_used") || ""), + why_it_matters: String(formData.get("why_it_matters") || ""), + }) + } + + if (responseMode === "json") { + return NextResponse.json({ message }) + } + + return NextResponse.redirect( + buildDraftRedirectUrl(request, redirectTo, { message }), + ) + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to save draft item." + + if (responseMode === "json") { + return NextResponse.json({ message }, { status: 400 }) + } + + return NextResponse.redirect( + buildDraftRedirectUrl(request, redirectTo, { error: message }), + ) + } +} \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/draft-original-pieces/[pieceId]/route.test.ts b/frontend/src/app/api/projects/[id]/draft-original-pieces/[pieceId]/route.test.ts new file mode 100644 index 00000000..dc6a416c --- /dev/null +++ b/frontend/src/app/api/projects/[id]/draft-original-pieces/[pieceId]/route.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + deleteProjectNewsletterDraftOriginalPiece, + updateProjectNewsletterDraftOriginalPiece, +} from "@/lib/api" + +import { POST } from "./route" + +vi.mock("@/lib/api", () => ({ + deleteProjectNewsletterDraftOriginalPiece: vi.fn(), + updateProjectNewsletterDraftOriginalPiece: vi.fn(), +})) + +describe("POST /api/projects/[id]/draft-original-pieces/[pieceId]", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns JSON for original-piece updates", async () => { + vi.mocked(updateProjectNewsletterDraftOriginalPiece).mockResolvedValue(undefined as never) + + const formData = new FormData() + formData.set("title", "Updated original piece") + formData.set("pitch", "Updated pitch") + formData.set("suggested_outline", "1. Updated outline") + + const response = await POST( + new Request( + "http://localhost/api/projects/4/draft-original-pieces/31?mode=json", + { + method: "POST", + body: formData, + }, + ), + { + params: Promise.resolve({ id: "4", pieceId: "31" }), + }, + ) + + expect(updateProjectNewsletterDraftOriginalPiece).toHaveBeenCalledWith(31, 4, { + title: "Updated original piece", + pitch: "Updated pitch", + suggested_outline: "1. Updated outline", + }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + message: "Original piece updated.", + }) + }) + + it("swaps original-piece order when moving a piece", async () => { + vi.mocked(updateProjectNewsletterDraftOriginalPiece).mockResolvedValue(undefined as never) + + const formData = new FormData() + formData.set("intent", "move_down") + formData.set("currentOrder", "0") + formData.set("targetOrder", "1") + formData.set("swapWithId", "32") + + const response = await POST( + new Request( + "http://localhost/api/projects/4/draft-original-pieces/31?mode=json", + { + method: "POST", + body: formData, + }, + ), + { + params: Promise.resolve({ id: "4", pieceId: "31" }), + }, + ) + + expect(updateProjectNewsletterDraftOriginalPiece).toHaveBeenNthCalledWith(1, 31, 4, { + order: -1, + }) + expect(updateProjectNewsletterDraftOriginalPiece).toHaveBeenNthCalledWith(2, 32, 4, { + order: 0, + }) + expect(updateProjectNewsletterDraftOriginalPiece).toHaveBeenNthCalledWith(3, 31, 4, { + order: 1, + }) + await expect(response.json()).resolves.toEqual({ + message: "Original piece moved down.", + }) + }) + + it("deletes an original piece when requested", async () => { + vi.mocked(deleteProjectNewsletterDraftOriginalPiece).mockResolvedValue(undefined) + + const formData = new FormData() + formData.set("intent", "delete") + + const response = await POST( + new Request( + "http://localhost/api/projects/4/draft-original-pieces/31?mode=json", + { + method: "POST", + body: formData, + }, + ), + { + params: Promise.resolve({ id: "4", pieceId: "31" }), + }, + ) + + expect(deleteProjectNewsletterDraftOriginalPiece).toHaveBeenCalledWith(31, 4) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + message: "Original piece removed.", + }) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/draft-original-pieces/[pieceId]/route.ts b/frontend/src/app/api/projects/[id]/draft-original-pieces/[pieceId]/route.ts new file mode 100644 index 00000000..2186046e --- /dev/null +++ b/frontend/src/app/api/projects/[id]/draft-original-pieces/[pieceId]/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from "next/server" + +import { + deleteProjectNewsletterDraftOriginalPiece, + updateProjectNewsletterDraftOriginalPiece, +} from "@/lib/api" + +import { buildDraftRedirectUrl } from "../../draft-action-helpers" + +async function swapOriginalPieceOrder( + projectId: number, + pieceId: number, + currentOrder: number, + targetOrder: number, + swapWithId: number, +) { + await updateProjectNewsletterDraftOriginalPiece(pieceId, projectId, { + order: -1, + }) + await updateProjectNewsletterDraftOriginalPiece(swapWithId, projectId, { + order: currentOrder, + }) + await updateProjectNewsletterDraftOriginalPiece(pieceId, projectId, { + order: targetOrder, + }) +} + +/** + * Handle inline original-piece updates, deletes, and reordering actions. + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string; pieceId: string }> }, +) { + const { id, pieceId } = await context.params + const responseMode = new URL(request.url).searchParams.get("mode") + const formData = await request.formData() + const redirectTo = String(formData.get("redirectTo") || `/drafts?project=${id}`) + + try { + const projectId = Number.parseInt(id, 10) + const resolvedPieceId = Number.parseInt(pieceId, 10) + const intent = String(formData.get("intent") || "update") + let message = "Original piece updated." + + if (intent === "delete") { + await deleteProjectNewsletterDraftOriginalPiece(resolvedPieceId, projectId) + message = "Original piece removed." + } else if (intent === "move_up" || intent === "move_down") { + const currentOrder = Number.parseInt( + String(formData.get("currentOrder") || "0"), + 10, + ) + const targetOrder = Number.parseInt( + String(formData.get("targetOrder") || String(currentOrder)), + 10, + ) + const swapWithId = Number.parseInt( + String(formData.get("swapWithId") || "0"), + 10, + ) + + await swapOriginalPieceOrder( + projectId, + resolvedPieceId, + currentOrder, + targetOrder, + swapWithId, + ) + message = intent === "move_up" ? "Original piece moved up." : "Original piece moved down." + } else { + await updateProjectNewsletterDraftOriginalPiece(resolvedPieceId, projectId, { + title: String(formData.get("title") || ""), + pitch: String(formData.get("pitch") || ""), + suggested_outline: String(formData.get("suggested_outline") || ""), + }) + } + + if (responseMode === "json") { + return NextResponse.json({ message }) + } + + return NextResponse.redirect( + buildDraftRedirectUrl(request, redirectTo, { message }), + ) + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to save original piece." + + if (responseMode === "json") { + return NextResponse.json({ message }, { status: 400 }) + } + + return NextResponse.redirect( + buildDraftRedirectUrl(request, redirectTo, { error: message }), + ) + } +} \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/draft-sections/[sectionId]/route.test.ts b/frontend/src/app/api/projects/[id]/draft-sections/[sectionId]/route.test.ts new file mode 100644 index 00000000..364b56cf --- /dev/null +++ b/frontend/src/app/api/projects/[id]/draft-sections/[sectionId]/route.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + deleteProjectNewsletterDraftSection, + updateProjectNewsletterDraftSection, +} from "@/lib/api" + +import { POST } from "./route" + +vi.mock("@/lib/api", () => ({ + deleteProjectNewsletterDraftSection: vi.fn(), + updateProjectNewsletterDraftSection: vi.fn(), +})) + +describe("POST /api/projects/[id]/draft-sections/[sectionId]", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns JSON for section updates", async () => { + vi.mocked(updateProjectNewsletterDraftSection).mockResolvedValue(undefined as never) + + const formData = new FormData() + formData.set("title", "Updated title") + formData.set("lede", "Updated lede") + + const response = await POST( + new Request("http://localhost/api/projects/4/draft-sections/12?mode=json", { + method: "POST", + body: formData, + }), + { + params: Promise.resolve({ id: "4", sectionId: "12" }), + }, + ) + + expect(updateProjectNewsletterDraftSection).toHaveBeenCalledWith(12, 4, { + title: "Updated title", + lede: "Updated lede", + }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ message: "Section updated." }) + }) + + it("swaps section order when moving a section", async () => { + vi.mocked(updateProjectNewsletterDraftSection).mockResolvedValue(undefined as never) + + const formData = new FormData() + formData.set("intent", "move_up") + formData.set("currentOrder", "2") + formData.set("targetOrder", "1") + formData.set("swapWithId", "11") + + const response = await POST( + new Request("http://localhost/api/projects/4/draft-sections/12?mode=json", { + method: "POST", + body: formData, + }), + { + params: Promise.resolve({ id: "4", sectionId: "12" }), + }, + ) + + expect(updateProjectNewsletterDraftSection).toHaveBeenNthCalledWith(1, 12, 4, { + order: -1, + }) + expect(updateProjectNewsletterDraftSection).toHaveBeenNthCalledWith(2, 11, 4, { + order: 2, + }) + expect(updateProjectNewsletterDraftSection).toHaveBeenNthCalledWith(3, 12, 4, { + order: 1, + }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ message: "Section moved up." }) + }) + + it("deletes a section when requested", async () => { + vi.mocked(deleteProjectNewsletterDraftSection).mockResolvedValue(undefined) + + const formData = new FormData() + formData.set("intent", "delete") + + const response = await POST( + new Request("http://localhost/api/projects/4/draft-sections/12?mode=json", { + method: "POST", + body: formData, + }), + { + params: Promise.resolve({ id: "4", sectionId: "12" }), + }, + ) + + expect(deleteProjectNewsletterDraftSection).toHaveBeenCalledWith(12, 4) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ message: "Section removed." }) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/draft-sections/[sectionId]/route.ts b/frontend/src/app/api/projects/[id]/draft-sections/[sectionId]/route.ts new file mode 100644 index 00000000..652674fd --- /dev/null +++ b/frontend/src/app/api/projects/[id]/draft-sections/[sectionId]/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server" + +import { + deleteProjectNewsletterDraftSection, + updateProjectNewsletterDraftSection, +} from "@/lib/api" + +import { buildDraftRedirectUrl } from "../../draft-action-helpers" + +async function swapSectionOrder( + projectId: number, + sectionId: number, + currentOrder: number, + targetOrder: number, + swapWithId: number, +) { + await updateProjectNewsletterDraftSection(sectionId, projectId, { order: -1 }) + await updateProjectNewsletterDraftSection(swapWithId, projectId, { + order: currentOrder, + }) + await updateProjectNewsletterDraftSection(sectionId, projectId, { + order: targetOrder, + }) +} + +/** + * Handle inline draft-section updates, deletes, and reordering actions. + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string; sectionId: string }> }, +) { + const { id, sectionId } = await context.params + const responseMode = new URL(request.url).searchParams.get("mode") + const formData = await request.formData() + const redirectTo = String(formData.get("redirectTo") || `/drafts?project=${id}`) + + try { + const projectId = Number.parseInt(id, 10) + const resolvedSectionId = Number.parseInt(sectionId, 10) + const intent = String(formData.get("intent") || "update") + let message = "Section updated." + + if (intent === "delete") { + await deleteProjectNewsletterDraftSection(resolvedSectionId, projectId) + message = "Section removed." + } else if (intent === "move_up" || intent === "move_down") { + const currentOrder = Number.parseInt( + String(formData.get("currentOrder") || "0"), + 10, + ) + const targetOrder = Number.parseInt( + String(formData.get("targetOrder") || String(currentOrder)), + 10, + ) + const swapWithId = Number.parseInt( + String(formData.get("swapWithId") || "0"), + 10, + ) + + await swapSectionOrder( + projectId, + resolvedSectionId, + currentOrder, + targetOrder, + swapWithId, + ) + message = intent === "move_up" ? "Section moved up." : "Section moved down." + } else { + await updateProjectNewsletterDraftSection(resolvedSectionId, projectId, { + title: String(formData.get("title") || ""), + lede: String(formData.get("lede") || ""), + }) + } + + if (responseMode === "json") { + return NextResponse.json({ message }) + } + + return NextResponse.redirect( + buildDraftRedirectUrl(request, redirectTo, { message }), + ) + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to save draft section." + + if (responseMode === "json") { + return NextResponse.json({ message }, { status: 400 }) + } + + return NextResponse.redirect( + buildDraftRedirectUrl(request, redirectTo, { error: message }), + ) + } +} \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/drafts/[draftId]/regenerate-section/route.test.ts b/frontend/src/app/api/projects/[id]/drafts/[draftId]/regenerate-section/route.test.ts new file mode 100644 index 00000000..a39bb14e --- /dev/null +++ b/frontend/src/app/api/projects/[id]/drafts/[draftId]/regenerate-section/route.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { regenerateProjectNewsletterDraftSection } from "@/lib/api" + +import { POST } from "./route" + +vi.mock("@/lib/api", () => ({ + regenerateProjectNewsletterDraftSection: vi.fn(), +})) + +function buildRequest(formData: FormData) { + return new Request("http://localhost/api/projects/4/drafts/9/regenerate-section", { + method: "POST", + body: formData, + }) +} + +async function getLocation(response: Response) { + return response.headers.get("location") +} + +describe("POST /api/projects/[id]/drafts/[draftId]/regenerate-section", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("redirects with a success message when regeneration completes immediately", async () => { + vi.mocked(regenerateProjectNewsletterDraftSection).mockResolvedValue({ + id: 9, + project: 4, + title: "Updated draft", + intro: "Intro", + outro: "Outro", + target_publish_date: null, + status: "edited", + generated_at: "2026-05-03T09:00:00Z", + last_edited_at: "2026-05-03T10:00:00Z", + generation_metadata: { + source_theme_ids: [1, 2], + source_idea_ids: [4], + }, + sections: [], + original_pieces: [], + rendered_markdown: "# Draft", + rendered_html: "

Draft

", + }) + + const formData = new FormData() + formData.set("redirectTo", "/drafts/9?project=4") + formData.set("sectionId", "12") + + const response = await POST(buildRequest(formData), { + params: Promise.resolve({ id: "4", draftId: "9" }), + }) + + expect(regenerateProjectNewsletterDraftSection).toHaveBeenCalledWith(4, 9, 12) + expect(response.status).toBe(307) + await expect(getLocation(response)).resolves.toBe( + "http://localhost/drafts/9?project=4&message=Draft+section+regenerated.", + ) + }) + + it("redirects with a queued message when regeneration is deferred", async () => { + vi.mocked(regenerateProjectNewsletterDraftSection).mockResolvedValue({ + status: "queued", + draft_id: 9, + section_id: 12, + }) + + const formData = new FormData() + formData.set("sectionId", "12") + + const response = await POST(buildRequest(formData), { + params: Promise.resolve({ id: "4", draftId: "9" }), + }) + + expect(response.status).toBe(307) + await expect(getLocation(response)).resolves.toBe( + "http://localhost/drafts/9?project=4&message=Draft+section+regeneration+queued.", + ) + }) + + it("returns JSON mode responses for inline regeneration", async () => { + vi.mocked(regenerateProjectNewsletterDraftSection).mockResolvedValue({ + status: "queued", + draft_id: 9, + section_id: 12, + }) + + const formData = new FormData() + formData.set("sectionId", "12") + + const response = await POST( + new Request( + "http://localhost/api/projects/4/drafts/9/regenerate-section?mode=json", + { + method: "POST", + body: formData, + }, + ), + { + params: Promise.resolve({ id: "4", draftId: "9" }), + }, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + message: "Draft section regeneration queued.", + }) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/drafts/[draftId]/regenerate-section/route.ts b/frontend/src/app/api/projects/[id]/drafts/[draftId]/regenerate-section/route.ts new file mode 100644 index 00000000..d0feb76c --- /dev/null +++ b/frontend/src/app/api/projects/[id]/drafts/[draftId]/regenerate-section/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server" + +import { regenerateProjectNewsletterDraftSection } from "@/lib/api" + +import { buildDraftRedirectUrl } from "../../../draft-action-helpers" + +/** + * Handle per-section newsletter draft regeneration requests. + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string; draftId: string }> }, +) { + const { id, draftId } = await context.params + const responseMode = new URL(request.url).searchParams.get("mode") + const formData = await request.formData() + const redirectTo = String(formData.get("redirectTo") || `/drafts/${draftId}?project=${id}`) + + try { + const response = await regenerateProjectNewsletterDraftSection( + Number.parseInt(id, 10), + Number.parseInt(draftId, 10), + Number.parseInt(String(formData.get("sectionId") || "0"), 10), + ) + const message = "section_id" in response + ? "Draft section regeneration queued." + : "Draft section regenerated." + + if (responseMode === "json") { + return NextResponse.json({ message }) + } + + return NextResponse.redirect( + buildDraftRedirectUrl(request, redirectTo, { message }), + ) + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to regenerate draft section." + + if (responseMode === "json") { + return NextResponse.json({ message }, { status: 400 }) + } + + return NextResponse.redirect( + buildDraftRedirectUrl(request, redirectTo, { error: message }), + ) + } +} \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/drafts/[draftId]/route.test.ts b/frontend/src/app/api/projects/[id]/drafts/[draftId]/route.test.ts new file mode 100644 index 00000000..480385df --- /dev/null +++ b/frontend/src/app/api/projects/[id]/drafts/[draftId]/route.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { updateProjectNewsletterDraft } from "@/lib/api" + +import { POST } from "./route" + +vi.mock("@/lib/api", () => ({ + updateProjectNewsletterDraft: vi.fn(), +})) + +function buildRequest(formData: FormData) { + return new Request("http://localhost/api/projects/4/drafts/9", { + method: "POST", + body: formData, + }) +} + +async function getLocation(response: Response) { + return response.headers.get("location") +} + +describe("POST /api/projects/[id]/drafts/[draftId]", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("updates the draft and redirects with a success message", async () => { + vi.mocked(updateProjectNewsletterDraft).mockResolvedValue(undefined as never) + + const formData = new FormData() + formData.set("redirectTo", "/drafts/9?project=4") + formData.set("title", "Updated draft") + formData.set("intro", "Updated intro") + formData.set("outro", "Updated outro") + formData.set("target_publish_date", "2026-05-10") + + const response = await POST(buildRequest(formData), { + params: Promise.resolve({ id: "4", draftId: "9" }), + }) + + expect(updateProjectNewsletterDraft).toHaveBeenCalledWith(4, 9, { + title: "Updated draft", + intro: "Updated intro", + outro: "Updated outro", + target_publish_date: "2026-05-10", + }) + expect(response.status).toBe(307) + await expect(getLocation(response)).resolves.toBe( + "http://localhost/drafts/9?project=4&message=Draft+updated.", + ) + }) + + it("normalizes an empty publish date to null", async () => { + vi.mocked(updateProjectNewsletterDraft).mockResolvedValue(undefined as never) + + const formData = new FormData() + formData.set("title", "Updated draft") + formData.set("intro", "") + formData.set("outro", "") + formData.set("target_publish_date", "") + + await POST(buildRequest(formData), { + params: Promise.resolve({ id: "4", draftId: "9" }), + }) + + expect(updateProjectNewsletterDraft).toHaveBeenCalledWith(4, 9, { + title: "Updated draft", + intro: "", + outro: "", + target_publish_date: null, + }) + }) + + it("returns JSON mode responses for client-side saves", async () => { + vi.mocked(updateProjectNewsletterDraft).mockResolvedValue(undefined as never) + + const formData = new FormData() + formData.set("title", "Updated draft") + formData.set("intro", "Updated intro") + formData.set("outro", "Updated outro") + formData.set("target_publish_date", "2026-05-10") + + const response = await POST( + new Request("http://localhost/api/projects/4/drafts/9?mode=json", { + method: "POST", + body: formData, + }), + { + params: Promise.resolve({ id: "4", draftId: "9" }), + }, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ message: "Draft updated." }) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/drafts/[draftId]/route.ts b/frontend/src/app/api/projects/[id]/drafts/[draftId]/route.ts new file mode 100644 index 00000000..d85ce638 --- /dev/null +++ b/frontend/src/app/api/projects/[id]/drafts/[draftId]/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server" + +import { updateProjectNewsletterDraft } from "@/lib/api" + +import { buildDraftRedirectUrl } from "../../draft-action-helpers" + +/** + * Handle top-level newsletter draft edits. + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string; draftId: string }> }, +) { + const { id, draftId } = await context.params + const responseMode = new URL(request.url).searchParams.get("mode") + const formData = await request.formData() + const redirectTo = String(formData.get("redirectTo") || `/drafts/${draftId}?project=${id}`) + + try { + await updateProjectNewsletterDraft(Number.parseInt(id, 10), Number.parseInt(draftId, 10), { + title: String(formData.get("title") || ""), + intro: String(formData.get("intro") || ""), + outro: String(formData.get("outro") || ""), + target_publish_date: String(formData.get("target_publish_date") || "") || null, + }) + + if (responseMode === "json") { + return NextResponse.json({ message: "Draft updated." }) + } + + return NextResponse.redirect( + buildDraftRedirectUrl(request, redirectTo, { message: "Draft updated." }), + ) + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to save newsletter draft." + + if (responseMode === "json") { + return NextResponse.json({ message }, { status: 400 }) + } + + return NextResponse.redirect( + buildDraftRedirectUrl(request, redirectTo, { error: message }), + ) + } +} \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/drafts/generate/route.test.ts b/frontend/src/app/api/projects/[id]/drafts/generate/route.test.ts new file mode 100644 index 00000000..b205cfec --- /dev/null +++ b/frontend/src/app/api/projects/[id]/drafts/generate/route.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + generateProjectNewsletterDraft, + isCompletedNewsletterDraftGeneration, +} from "@/lib/api" + +import { POST } from "./route" + +vi.mock("@/lib/api", () => ({ + generateProjectNewsletterDraft: vi.fn(), + isCompletedNewsletterDraftGeneration: vi.fn(), +})) + +function buildRequest(formData: FormData) { + return new Request("http://localhost/api/projects/4/drafts/generate", { + method: "POST", + body: formData, + }) +} + +async function getLocation(response: Response) { + return response.headers.get("location") +} + +describe("POST /api/projects/[id]/drafts/generate", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(isCompletedNewsletterDraftGeneration).mockImplementation( + (response) => response.status === "completed", + ) + }) + + it("redirects with a success message when a draft is generated immediately", async () => { + vi.mocked(generateProjectNewsletterDraft).mockResolvedValue({ + status: "completed", + project_id: 4, + result: { + project_id: 4, + draft_id: 9, + status: "ready", + sections_created: 2, + original_pieces_created: 1, + }, + }) + + const formData = new FormData() + formData.set("redirectTo", "/drafts?project=4&status=ready") + + const response = await POST(buildRequest(formData), { + params: Promise.resolve({ id: "4" }), + }) + + expect(generateProjectNewsletterDraft).toHaveBeenCalledWith(4) + expect(response.status).toBe(307) + await expect(getLocation(response)).resolves.toBe( + "http://localhost/drafts?project=4&status=ready&message=Newsletter+draft+generated.", + ) + }) + + it("redirects with an informative no-op message when inputs are insufficient", async () => { + vi.mocked(generateProjectNewsletterDraft).mockResolvedValue({ + status: "completed", + project_id: 4, + result: { + project_id: 4, + draft_id: null, + status: "skipped", + reason: "insufficient_inputs", + sections_created: 0, + original_pieces_created: 0, + }, + }) + + const response = await POST(buildRequest(new FormData()), { + params: Promise.resolve({ id: "4" }), + }) + + expect(response.status).toBe(307) + await expect(getLocation(response)).resolves.toBe( + "http://localhost/drafts?project=4&message=No+newsletter+draft+was+created+because+the+project+needs+at+least+two+accepted+themes+and+one+accepted+original+idea.", + ) + }) + + it("redirects with a queued message when generation is deferred", async () => { + vi.mocked(generateProjectNewsletterDraft).mockResolvedValue({ + status: "queued", + project_id: 4, + }) + + const response = await POST(buildRequest(new FormData()), { + params: Promise.resolve({ id: "4" }), + }) + + expect(response.status).toBe(307) + await expect(getLocation(response)).resolves.toBe( + "http://localhost/drafts?project=4&message=Newsletter+draft+generation+queued.", + ) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/drafts/generate/route.ts b/frontend/src/app/api/projects/[id]/drafts/generate/route.ts new file mode 100644 index 00000000..44477687 --- /dev/null +++ b/frontend/src/app/api/projects/[id]/drafts/generate/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server" + +import { + generateProjectNewsletterDraft, + isCompletedNewsletterDraftGeneration, +} from "@/lib/api" + +import { buildDraftRedirectUrl } from "../../draft-action-helpers" + +/** + * Handle manual newsletter draft generation requests from the drafts queue. + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + const { id } = await context.params + const formData = await request.formData() + const redirectTo = String(formData.get("redirectTo") || `/drafts?project=${id}`) + + try { + const projectId = Number.parseInt(id, 10) + const response = await generateProjectNewsletterDraft(projectId) + const message = isCompletedNewsletterDraftGeneration(response) + ? response.result.draft_id + ? "Newsletter draft generated." + : response.result.reason === "insufficient_inputs" + ? "No newsletter draft was created because the project needs at least two accepted themes and one accepted original idea." + : "No newsletter draft was created." + : "Newsletter draft generation queued." + + return NextResponse.redirect( + buildDraftRedirectUrl(request, redirectTo, { message }), + ) + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to generate newsletter draft." + return NextResponse.redirect( + buildDraftRedirectUrl(request, redirectTo, { error: message }), + ) + } +} \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/entity-candidate-bulk/route.test.ts b/frontend/src/app/api/projects/[id]/entity-candidate-bulk/route.test.ts new file mode 100644 index 00000000..9fa513ba --- /dev/null +++ b/frontend/src/app/api/projects/[id]/entity-candidate-bulk/route.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + acceptEntityCandidate, + mergeEntityCandidate, + rejectEntityCandidate, +} from "@/lib/api" + +import { POST } from "./route" + +vi.mock("@/lib/api", () => ({ + acceptEntityCandidate: vi.fn(), + mergeEntityCandidate: vi.fn(), + rejectEntityCandidate: vi.fn(), +})) + +describe("POST /api/projects/[id]/entity-candidate-bulk", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("accepts all candidates in the submitted cluster", async () => { + const formData = new FormData() + formData.append("candidateId", "14") + formData.append("candidateId", "15") + formData.set("redirectTo", "/entities/candidates?project=4") + + const response = await POST( + new Request("http://localhost/api/projects/4/entity-candidate-bulk", { + method: "POST", + body: formData, + }), + { params: Promise.resolve({ id: "4" }) }, + ) + + expect(acceptEntityCandidate).toHaveBeenNthCalledWith(1, 14, 4) + expect(acceptEntityCandidate).toHaveBeenNthCalledWith(2, 15, 4) + expect(response.status).toBe(307) + expect(response.headers.get("location")).toBe( + "http://localhost/entities/candidates?project=4&message=Accepted+2+candidates.", + ) + }) + + it("rejects all candidates in the submitted cluster", async () => { + const formData = new FormData() + formData.append("candidateId", "14") + formData.set("intent", "reject") + + const response = await POST( + new Request("http://localhost/api/projects/4/entity-candidate-bulk", { + method: "POST", + body: formData, + }), + { params: Promise.resolve({ id: "4" }) }, + ) + + expect(rejectEntityCandidate).toHaveBeenCalledWith(14, 4) + expect(response.headers.get("location")).toBe( + "http://localhost/entities/candidates?project=4&message=Rejected+1+candidate.", + ) + }) + + it("merges all candidates into the selected entity", async () => { + const formData = new FormData() + formData.append("candidateId", "14") + formData.append("candidateId", "15") + formData.set("intent", "merge") + formData.set("mergedInto", "9") + + const response = await POST( + new Request("http://localhost/api/projects/4/entity-candidate-bulk", { + method: "POST", + body: formData, + }), + { params: Promise.resolve({ id: "4" }) }, + ) + + expect(mergeEntityCandidate).toHaveBeenNthCalledWith(1, 14, 4, 9) + expect(mergeEntityCandidate).toHaveBeenNthCalledWith(2, 15, 4, 9) + expect(response.headers.get("location")).toBe( + "http://localhost/entities/candidates?project=4&message=Merged+2+candidates.", + ) + }) + + it("returns a validation error when merge target is missing", async () => { + const formData = new FormData() + formData.append("candidateId", "14") + formData.set("intent", "merge") + + const response = await POST( + new Request( + "http://localhost/api/projects/4/entity-candidate-bulk?mode=json", + { + method: "POST", + body: formData, + }, + ), + { params: Promise.resolve({ id: "4" }) }, + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + message: "Select an entity to merge into.", + }) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/entity-candidate-bulk/route.ts b/frontend/src/app/api/projects/[id]/entity-candidate-bulk/route.ts new file mode 100644 index 00000000..05c35010 --- /dev/null +++ b/frontend/src/app/api/projects/[id]/entity-candidate-bulk/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server" + +import { + acceptEntityCandidate, + mergeEntityCandidate, + rejectEntityCandidate, +} from "@/lib/api" + +function buildRedirectUrl( + request: Request, + redirectTo: string, + params: Record, +) { + const url = new URL(redirectTo || "/entities/candidates", request.url) + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + return url +} + +/** + * Handle bulk candidate review actions for one clustered candidate group. + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + const { id } = await context.params + const responseMode = new URL(request.url).searchParams.get("mode") + const formData = await request.formData() + const redirectTo = String( + formData.get("redirectTo") || `/entities/candidates?project=${id}`, + ) + + try { + const projectId = Number.parseInt(id, 10) + const intent = String(formData.get("intent") || "accept") + const candidateIds = formData + .getAll("candidateId") + .map((value) => Number.parseInt(String(value), 10)) + .filter((candidateId) => Number.isInteger(candidateId) && candidateId > 0) + + if (candidateIds.length === 0) { + throw new Error("Select at least one entity candidate.") + } + + let message = "Candidates accepted." + + if (intent === "reject") { + for (const candidateId of candidateIds) { + await rejectEntityCandidate(candidateId, projectId) + } + message = `Rejected ${candidateIds.length} candidate${candidateIds.length === 1 ? "" : "s"}.` + } else if (intent === "merge") { + const mergedInto = Number.parseInt( + String(formData.get("mergedInto") || "0"), + 10, + ) + if (!Number.isInteger(mergedInto) || mergedInto <= 0) { + throw new Error("Select an entity to merge into.") + } + for (const candidateId of candidateIds) { + await mergeEntityCandidate(candidateId, projectId, mergedInto) + } + message = `Merged ${candidateIds.length} candidate${candidateIds.length === 1 ? "" : "s"}.` + } else { + for (const candidateId of candidateIds) { + await acceptEntityCandidate(candidateId, projectId) + } + message = `Accepted ${candidateIds.length} candidate${candidateIds.length === 1 ? "" : "s"}.` + } + + if (responseMode === "json") { + return NextResponse.json({ message }) + } + + return NextResponse.redirect(buildRedirectUrl(request, redirectTo, { message })) + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Unable to update entity candidates." + + if (responseMode === "json") { + return NextResponse.json({ message }, { status: 400 }) + } + + return NextResponse.redirect(buildRedirectUrl(request, redirectTo, { error: message })) + } +} \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/linkedin-source-configs/route.test.ts b/frontend/src/app/api/projects/[id]/linkedin-source-configs/route.test.ts new file mode 100644 index 00000000..bb422388 --- /dev/null +++ b/frontend/src/app/api/projects/[id]/linkedin-source-configs/route.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { createSourceConfig } from "@/lib/api" + +import { POST } from "./route" + +vi.mock("@/lib/api", () => ({ + createSourceConfig: vi.fn(), +})) + +describe("POST /api/projects/[id]/linkedin-source-configs", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("creates an organization LinkedIn source", async () => { + const formData = new FormData() + formData.set("surface", "organization") + formData.set("urn", "urn:li:organization:1337") + formData.set("max_posts_per_fetch", "75") + formData.set("redirectTo", "/admin/sources?project=4") + + const response = await POST( + new Request("http://localhost/api/projects/4/linkedin-source-configs", { + method: "POST", + body: formData, + }), + { params: Promise.resolve({ id: "4" }) }, + ) + + expect(createSourceConfig).toHaveBeenCalledWith(4, { + plugin_name: "linkedin", + config: { + organization_urn: "urn:li:organization:1337", + max_posts_per_fetch: 75, + }, + is_active: true, + }) + expect(response.headers.get("location")).toBe( + "http://localhost/admin/sources?project=4&message=LinkedIn+source+created.", + ) + }) + + it("creates a person LinkedIn source with reshare choice", async () => { + const formData = new FormData() + formData.set("surface", "person") + formData.set("urn", "urn:li:person:abc123") + formData.set("include_reshares", "true") + + await POST( + new Request("http://localhost/api/projects/4/linkedin-source-configs", { + method: "POST", + body: formData, + }), + { params: Promise.resolve({ id: "4" }) }, + ) + + expect(createSourceConfig).toHaveBeenCalledWith(4, { + plugin_name: "linkedin", + config: { + person_urn: "urn:li:person:abc123", + include_reshares: true, + }, + is_active: true, + }) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/api/projects/[id]/linkedin-source-configs/route.ts b/frontend/src/app/api/projects/[id]/linkedin-source-configs/route.ts new file mode 100644 index 00000000..e0591b0a --- /dev/null +++ b/frontend/src/app/api/projects/[id]/linkedin-source-configs/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server" + +import { createSourceConfig } from "@/lib/api" + +function buildRedirectUrl( + request: Request, + redirectTo: string, + params: Record, +) { + const url = new URL(redirectTo || "/admin/sources", request.url) + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + return url +} + +/** + * Handle typed LinkedIn source-config creation requests. + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + const { id } = await context.params + const formData = await request.formData() + const redirectTo = String(formData.get("redirectTo") || "/admin/sources") + + try { + const projectId = Number.parseInt(id, 10) + const surface = String(formData.get("surface") || "organization") + const urn = String(formData.get("urn") || "").trim() + if (!urn) { + throw new Error("Enter a LinkedIn URN.") + } + + const config: Record = {} + if (surface === "person") { + config.person_urn = urn + config.include_reshares = + String(formData.get("include_reshares") || "false") === "true" + } else if (surface === "newsletter") { + config.newsletter_urn = urn + config.max_posts_per_fetch = Number.parseInt( + String(formData.get("max_posts_per_fetch") || "25"), + 10, + ) + } else { + config.organization_urn = urn + config.max_posts_per_fetch = Number.parseInt( + String(formData.get("max_posts_per_fetch") || "50"), + 10, + ) + } + + await createSourceConfig(projectId, { + plugin_name: "linkedin", + config, + is_active: String(formData.get("is_active") || "true") === "true", + }) + + return NextResponse.redirect( + buildRedirectUrl(request, redirectTo, { message: "LinkedIn source created." }), + ) + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Unable to create LinkedIn source configuration." + return NextResponse.redirect(buildRedirectUrl(request, redirectTo, { error: message })) + } +} \ No newline at end of file diff --git a/frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.test.tsx b/frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.test.tsx new file mode 100644 index 00000000..276537f1 --- /dev/null +++ b/frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.test.tsx @@ -0,0 +1,230 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { DraftEditor } from "@/app/drafts/[draftId]/_components/DraftEditor" +import type { NewsletterDraft } from "@/lib/types" + +const { refreshMock } = vi.hoisted(() => ({ + refreshMock: vi.fn(), +})) + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + back: vi.fn(), + refresh: refreshMock, + }), +})) + +function createDraft(overrides: Partial = {}): NewsletterDraft { + return { + id: 8, + project: 1, + title: "AI Weekly: Delivery signals and more", + intro: "A quick editor-ready summary.", + outro: "Closing thought.", + target_publish_date: "2026-05-08", + status: "ready", + generated_at: "2026-05-03T09:00:00Z", + last_edited_at: null, + generation_metadata: { + source_theme_ids: [1, 2], + source_idea_ids: [4], + trigger_source: "manual", + models: { section_composer: "heuristic" }, + coherence_suggestions: ["Tighten the intro transition."], + }, + sections: [ + { + id: 21, + draft: 8, + theme_suggestion: 3, + theme_suggestion_detail: { + id: 3, + title: "Delivery signals", + pitch: "Pitch", + why_it_matters: "Why it matters", + }, + title: "Delivery signals", + lede: "Section lede.", + order: 0, + items: [ + { + id: 44, + section: 21, + content: 55, + content_detail: { + id: 55, + url: "https://example.com/post", + title: "Useful article", + source_plugin: "rss", + published_date: "2026-05-02T10:00:00Z", + }, + summary_used: "Item summary.", + why_it_matters: "Item why.", + order: 0, + }, + { + id: 45, + section: 21, + content: 56, + content_detail: { + id: 56, + url: "https://example.com/post-2", + title: "Second article", + source_plugin: "reddit", + published_date: "2026-05-02T11:00:00Z", + }, + summary_used: "Second summary.", + why_it_matters: "Second why.", + order: 1, + }, + ], + }, + { + id: 22, + draft: 8, + theme_suggestion: null, + theme_suggestion_detail: null, + title: "Second section", + lede: "Second lede.", + order: 1, + items: [], + }, + ], + original_pieces: [ + { + id: 31, + draft: 8, + idea: 9, + idea_detail: { + id: 9, + angle_title: "Original idea", + summary: "Summary", + suggested_outline: "1. Outline", + }, + title: "Original idea", + pitch: "Pitch", + suggested_outline: "1. Outline", + order: 0, + }, + { + id: 32, + draft: 8, + idea: 10, + idea_detail: { + id: 10, + angle_title: "Second idea", + summary: "Summary", + suggested_outline: "1. Outline", + }, + title: "Second idea", + pitch: "Pitch", + suggested_outline: "1. Outline", + order: 1, + }, + ], + rendered_markdown: "# Draft", + rendered_html: "

Draft

", + ...overrides, + } +} + +describe("DraftEditor", () => { + beforeEach(() => { + refreshMock.mockReset() + vi.restoreAllMocks() + }) + + it("saves top-level draft framing through the JSON route", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ message: "Draft updated." }), + }) + vi.stubGlobal("fetch", fetchMock) + + render( + , + ) + + fireEvent.change(screen.getByLabelText("Title"), { + target: { value: "Updated draft title" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Save draft framing" })) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:3000/api/projects/1/drafts/8?mode=json", + expect.objectContaining({ method: "POST" }), + ) + }) + + expect(await screen.findByRole("status")).toHaveTextContent("Draft updated.") + expect(refreshMock).toHaveBeenCalled() + }) + + it("removes a draft item through the JSON route", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ message: "Draft item removed." }), + }) + vi.stubGlobal("fetch", fetchMock) + + render( + , + ) + + fireEvent.click(screen.getAllByRole("button", { name: "Remove item" })[0]) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:3000/api/projects/1/draft-items/44?mode=json", + expect.objectContaining({ method: "POST" }), + ) + }) + + expect(await screen.findByRole("status")).toHaveTextContent( + "Draft item removed.", + ) + expect(refreshMock).toHaveBeenCalled() + }) + + it("reorders sections through the JSON route", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ message: "Section moved down." }), + }) + vi.stubGlobal("fetch", fetchMock) + + render( + , + ) + + fireEvent.click(screen.getAllByRole("button", { name: "Move down" })[0]) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:3000/api/projects/1/draft-sections/21?mode=json", + expect.objectContaining({ method: "POST" }), + ) + }) + + expect(await screen.findByRole("status")).toHaveTextContent( + "Section moved down.", + ) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.tsx b/frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.tsx new file mode 100644 index 00000000..b4fad608 --- /dev/null +++ b/frontend/src/app/drafts/[draftId]/_components/DraftEditor/index.tsx @@ -0,0 +1,643 @@ +"use client" + +import Link from "next/link" +import { useRouter } from "next/navigation" +import type { FormEvent } from "react" +import { useState } from "react" + +import type { NewsletterDraft } from "@/lib/types" + +type DraftEditorProps = { + /** Numeric project identifier used by the route handlers. */ + projectId: number + /** Redirect target reused by the draft route handlers for non-JS fallbacks. */ + currentPageHref: string + /** Fully expanded draft tree shown in the editor. */ + draft: NewsletterDraft +} + +type JsonDraftActionResponse = { + message?: string +} + +function appendJsonMode(route: string) { + const url = new URL(route, window.location.href) + url.searchParams.set("mode", "json") + return url.toString() +} + +/** + * Render the interactive newsletter draft editor. + * + * The editor saves through the local App Router route handlers in `mode=json`, so + * inline edits, deletions, and reorder actions complete with a lightweight refresh + * rather than a full-page navigation. The same form actions remain present in the + * markup for progressive enhancement when JavaScript is unavailable. + */ +export function DraftEditor({ + projectId, + currentPageHref, + draft, +}: DraftEditorProps) { + const router = useRouter() + const [pendingAction, setPendingAction] = useState(null) + const [statusMessage, setStatusMessage] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) + + async function submitJsonAction( + actionKey: string, + route: string, + formData: FormData, + fallbackError: string, + ) { + setPendingAction(actionKey) + setStatusMessage(null) + setErrorMessage(null) + + try { + const response = await fetch(appendJsonMode(route), { + method: "POST", + body: formData, + }) + const payload = + ((await response.json()) as JsonDraftActionResponse | null) ?? null + + if (!response.ok || !payload?.message) { + throw new Error(payload?.message || fallbackError) + } + + setStatusMessage(payload.message) + router.refresh() + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : fallbackError) + } finally { + setPendingAction(null) + } + } + + async function handleFormSubmit( + event: FormEvent, + actionKey: string, + route: string, + fallbackError: string, + ) { + event.preventDefault() + await submitJsonAction( + actionKey, + route, + new FormData(event.currentTarget), + fallbackError, + ) + } + + async function handleIntentAction( + actionKey: string, + route: string, + fields: Record, + fallbackError: string, + ) { + const formData = new FormData() + for (const [key, value] of Object.entries(fields)) { + formData.set(key, value) + } + await submitJsonAction(actionKey, route, formData, fallbackError) + } + + function isPending(actionKey: string) { + return pendingAction === actionKey + } + + const draftRoute = `/api/projects/${projectId}/drafts/${draft.id}` + const regenerateRoute = `${draftRoute}/regenerate-section` + + return ( +
+
+ {statusMessage ? ( +

+ {statusMessage} +

+ ) : null} + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + +
+
{ + void handleFormSubmit( + event, + `draft-save-${draft.id}`, + draftRoute, + "Unable to save newsletter draft.", + ) + }} + > + +
+ + +
+
+ + +
+
+ +