From f856e84b37b22466a4a544655b387f93a6045ed4 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 10 Jul 2026 07:55:07 -0300 Subject: [PATCH 1/2] feat(api)!: remove draft-proposal endpoints (moved to User API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drafts are now owned by the User API (user-scoped, session-authenticated), so the DAO APIs no longer serve them. Removes the /{dao}/proposal/drafts* routes and their controller/service/repository/mappers, the `general` schema wiring (pgGeneralClient + boot migration) and its drizzle config, and drops all Draft* symbols from the regenerated @anticapture/client (fetchers, hooks, MCP tools, models) — breaking for external SDK consumers. The physical general.proposal_drafts table is intentionally left in each DAO database for the one-shot migration; a later PR drops it. Co-Authored-By: Claude Fable 5 --- .../drafts-cutover-remove-dao-routes.md | 7 + apps/api/cmd/index.ts | 25 -- apps/api/drizzle.config.ts | 11 - apps/api/drizzle/0000_large_mac_gargan.sql | 20 -- apps/api/drizzle/meta/0000_snapshot.json | 123 ------- apps/api/drizzle/meta/_journal.json | 13 - apps/api/package.json | 5 +- .../draft-proposals.integration.test.ts | 325 ------------------ .../src/controllers/draft-proposals/index.ts | 169 --------- apps/api/src/controllers/index.ts | 1 - .../offchainProposals.integration.test.ts | 3 +- .../votes/offchainVotes.integration.test.ts | 3 +- apps/api/src/database/general-schema.ts | 19 - apps/api/src/database/index.ts | 6 - apps/api/src/mappers/draft-proposals/index.ts | 89 ----- apps/api/src/mappers/index.ts | 1 - .../src/repositories/draft-proposals/index.ts | 82 ----- apps/api/src/repositories/index.ts | 1 - .../proposals/offchainProposals.unit.test.ts | 3 +- .../votes/offchainNonVoters.unit.test.ts | 3 +- .../votes/offchainVotes.unit.test.ts | 3 +- .../api/src/services/draft-proposals/index.ts | 71 ---- apps/api/src/services/index.ts | 1 - 23 files changed, 13 insertions(+), 971 deletions(-) create mode 100644 .changeset/drafts-cutover-remove-dao-routes.md delete mode 100644 apps/api/drizzle.config.ts delete mode 100644 apps/api/drizzle/0000_large_mac_gargan.sql delete mode 100644 apps/api/drizzle/meta/0000_snapshot.json delete mode 100644 apps/api/drizzle/meta/_journal.json delete mode 100644 apps/api/src/controllers/draft-proposals/draft-proposals.integration.test.ts delete mode 100644 apps/api/src/controllers/draft-proposals/index.ts delete mode 100644 apps/api/src/database/general-schema.ts delete mode 100644 apps/api/src/mappers/draft-proposals/index.ts delete mode 100644 apps/api/src/repositories/draft-proposals/index.ts delete mode 100644 apps/api/src/services/draft-proposals/index.ts diff --git a/.changeset/drafts-cutover-remove-dao-routes.md b/.changeset/drafts-cutover-remove-dao-routes.md new file mode 100644 index 000000000..920aa6f17 --- /dev/null +++ b/.changeset/drafts-cutover-remove-dao-routes.md @@ -0,0 +1,7 @@ +--- +"@anticapture/api": minor +"@anticapture/gateful": minor +"@anticapture/client": major +--- + +Remove the draft-proposal endpoints from the DAO APIs — drafts now live in the User API (user-scoped, session-authenticated). The `/{dao}/proposal/drafts*` routes, their controller/service/repository/mappers, and the `general` Postgres schema wiring are gone from `@anticapture/api`; the gateway spec and the generated `@anticapture/client` SDK no longer expose any `Draft*` fetchers, hooks, MCP tools, or models (breaking for external SDK consumers). The physical `general.proposal_drafts` table is left intact in each DAO database for the one-shot migration into the User API; a follow-up drops it. diff --git a/apps/api/cmd/index.ts b/apps/api/cmd/index.ts index 932cffee6..72e405400 100644 --- a/apps/api/cmd/index.ts +++ b/apps/api/cmd/index.ts @@ -7,7 +7,6 @@ import { serve } from "@hono/node-server"; import { OpenAPIHono as Hono } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; import { drizzle } from "drizzle-orm/node-postgres"; -import { migrate } from "drizzle-orm/node-postgres/migrator"; import { createPublicClient, http } from "viem"; import { fromZodError } from "zod-validation-error"; import { DaoCache } from "@/cache/dao-cache"; @@ -17,7 +16,6 @@ import { accountInteractions, dao, delegationPercentage, - draftProposals, governanceActivity, historicalBalances, historicalVotingPower, @@ -44,7 +42,6 @@ import { health, revenue, } from "@/controllers"; -import * as generalSchema from "@/database/general-schema"; import * as offchainSchema from "@/database/offchain-schema"; import * as schema from "@/database/schema"; import { docs } from "@/docs"; @@ -61,7 +58,6 @@ import { AccountInteractionsRepository, BalanceVariationsRepository, DaoMetricsDayBucketRepository, - DraftProposalsRepository, DrizzleProposalsActivityRepository, DrizzleRepository, HealthRepositoryImpl, @@ -89,7 +85,6 @@ import { CoingeckoService, DaoService, DelegationPercentageService, - DraftProposalsService, HealthService, HistoricalBalancesService, NFTPriceService, @@ -186,17 +181,6 @@ const pgClient = drizzle(env.DATABASE_URL, { casing: "snake_case", }); -const pgGeneralClient = drizzle(env.DATABASE_URL, { - schema: generalSchema, - casing: "snake_case", -}); - -await migrate(pgGeneralClient, { - migrationsFolder: "./drizzle", - migrationsSchema: "general", -}); -logger.info("database migrations completed"); - health(app, new HealthService(new HealthRepositoryImpl(pgClient), daoClient)); const daoConfig = CONTRACT_ADDRESSES[env.DAO_ID]; @@ -363,15 +347,6 @@ votes( ), ); dao(app, daoService); -draftProposals( - app, - wrapWithTracing( - new DraftProposalsService( - wrapWithTracing(new DraftProposalsRepository(pgGeneralClient)), - ), - ), - env.DAO_ID.toLowerCase(), -); docs(app); tokenMetrics(app, tokenMetricsService); diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts deleted file mode 100644 index 916b77902..000000000 --- a/apps/api/drizzle.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "drizzle-kit"; - -export default defineConfig({ - schema: "./src/database/general-schema.ts", - out: "./drizzle", - dialect: "postgresql", - dbCredentials: { - url: process.env.DATABASE_URL!, - }, - schemaFilter: ["general"], -}); diff --git a/apps/api/drizzle/0000_large_mac_gargan.sql b/apps/api/drizzle/0000_large_mac_gargan.sql deleted file mode 100644 index e548ecfaa..000000000 --- a/apps/api/drizzle/0000_large_mac_gargan.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE SCHEMA IF NOT EXISTS "general"; - ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "general"."proposal_drafts" ( - "id" text PRIMARY KEY NOT NULL, - "dao_id" text NOT NULL, - "author" text NOT NULL, - "title" text DEFAULT '' NOT NULL, - "discussion_url" text DEFAULT '' NOT NULL, - "body" text DEFAULT '' NOT NULL, - "actions" jsonb DEFAULT '[]' :: jsonb NOT NULL, - "created_at" bigint NOT NULL, - "updated_at" bigint NOT NULL -); - ---> statement-breakpoint -CREATE INDEX IF NOT EXISTS "proposal_drafts_author_index" ON "general"."proposal_drafts" USING btree ("author"); - ---> statement-breakpoint -CREATE INDEX IF NOT EXISTS "proposal_drafts_dao_id_index" ON "general"."proposal_drafts" USING btree ("dao_id"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/api/drizzle/meta/0000_snapshot.json deleted file mode 100644 index 8485c0709..000000000 --- a/apps/api/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "id": "1e4542ba-754f-4b5f-a22b-0b52a98b6364", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "general.proposal_drafts": { - "name": "proposal_drafts", - "schema": "general", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "dao_id": { - "name": "dao_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "author": { - "name": "author", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "discussion_url": { - "name": "discussion_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "body": { - "name": "body", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "actions": { - "name": "actions", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "proposal_drafts_author_index": { - "name": "proposal_drafts_author_index", - "columns": [ - { - "expression": "author", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "proposal_drafts_dao_id_index": { - "name": "proposal_drafts_dao_id_index", - "columns": [ - { - "expression": "dao_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": { - "general": "general" - }, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json deleted file mode 100644 index e14c12ebe..000000000 --- a/apps/api/drizzle/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1778851591748, - "tag": "0000_large_mac_gargan", - "breakpoints": true - } - ] -} diff --git a/apps/api/package.json b/apps/api/package.json index 67caee4b1..a50930a7f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,10 +16,7 @@ "build:watch": "tsc --watch", "lint": "eslint src", "lint:fix": "eslint src --fix", - "dev": "tsx watch cmd/index.ts", - "db:push": "drizzle-kit push", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate" + "dev": "tsx watch cmd/index.ts" }, "keywords": [], "author": "", diff --git a/apps/api/src/controllers/draft-proposals/draft-proposals.integration.test.ts b/apps/api/src/controllers/draft-proposals/draft-proposals.integration.test.ts deleted file mode 100644 index dce0460c3..000000000 --- a/apps/api/src/controllers/draft-proposals/draft-proposals.integration.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { OpenAPIHono as Hono } from "@hono/zod-openapi"; -import { PGlite } from "@electric-sql/pglite"; -import { pushSchema } from "drizzle-kit/api"; -import { drizzle } from "drizzle-orm/pglite"; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -import type { GeneralDrizzle } from "@/database"; -import * as generalSchema from "@/database/general-schema"; -import { proposalDrafts } from "@/database/general-schema"; -import { DraftProposalsRepository } from "@/repositories/draft-proposals"; -import { DraftProposalsService } from "@/services/draft-proposals"; -import { draftProposals } from "."; - -const DAO_ID = "ens"; -const ADDRESS_A = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const ADDRESS_B = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - -type DraftInsert = typeof proposalDrafts.$inferInsert; - -const makeDraft = (overrides: Partial = {}): DraftInsert => ({ - id: crypto.randomUUID(), - daoId: DAO_ID, - author: ADDRESS_A, - title: "Test draft", - discussionUrl: "https://example.com", - body: "Draft body", - actions: [], - createdAt: BigInt(Date.now()), - updatedAt: BigInt(Date.now()), - ...overrides, -}); - -describe("draftProposals controller", () => { - let client: PGlite; - let db: GeneralDrizzle; - let app: Hono; - - beforeAll(async () => { - client = new PGlite(); - db = drizzle(client, { schema: generalSchema }); - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const { apply } = await pushSchema(generalSchema, db as any); - await apply(); - - const repo = new DraftProposalsRepository(db); - const service = new DraftProposalsService(repo); - app = new Hono(); - draftProposals(app, service, DAO_ID); - }); - - afterAll(async () => { - await client.close(); - }); - - beforeEach(async () => { - await db.delete(proposalDrafts); - }); - - describe("GET /proposal/drafts", () => { - it("returns an empty list when no drafts exist for the address", async () => { - const res = await app.request(`/proposal/drafts?address=${ADDRESS_A}`); - expect(res.status).toBe(200); - await expect(res.json()).resolves.toEqual({ items: [] }); - }); - - it("returns drafts belonging to the given address", async () => { - const draft = makeDraft(); - await db.insert(proposalDrafts).values(draft); - - const res = await app.request(`/proposal/drafts?address=${ADDRESS_A}`); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.items).toHaveLength(1); - expect(body.items[0].id).toBe(draft.id); - expect(body.items[0].title).toBe(draft.title); - }); - - it("does not return drafts belonging to a different address", async () => { - await db.insert(proposalDrafts).values(makeDraft({ author: ADDRESS_B })); - - const res = await app.request(`/proposal/drafts?address=${ADDRESS_A}`); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.items).toHaveLength(0); - }); - - it("returns 400 when address query param is missing", async () => { - const res = await app.request("/proposal/drafts"); - expect(res.status).toBe(400); - }); - }); - - describe("GET /proposal/drafts/:id", () => { - it("returns the draft for any caller (public share endpoint)", async () => { - const draft = makeDraft({ author: ADDRESS_A }); - await db.insert(proposalDrafts).values(draft); - - const res = await app.request(`/proposal/drafts/${draft.id}`); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.id).toBe(draft.id); - expect(body.author).toBe(ADDRESS_A); - }); - - it("returns 404 when the draft does not exist", async () => { - const res = await app.request(`/proposal/drafts/${crypto.randomUUID()}`); - expect(res.status).toBe(404); - }); - - it("returns 404 when fetching a draft that belongs to a different dao", async () => { - const draft = makeDraft({ daoId: "other-dao" }); - await db.insert(proposalDrafts).values(draft); - - const res = await app.request(`/proposal/drafts/${draft.id}`); - expect(res.status).toBe(404); - }); - }); - - describe("POST /proposal/drafts", () => { - it("creates a draft and returns 201 with the saved data", async () => { - const id = crypto.randomUUID(); - const res = await app.request("/proposal/drafts", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - id, - address: ADDRESS_A, - title: "My proposal", - body: "Proposal body text", - discussionUrl: "https://forum.example.com", - actions: [], - }), - }); - - expect(res.status).toBe(201); - const body = await res.json(); - expect(body.id).toBe(id); - expect(body.title).toBe("My proposal"); - expect(body.author).toBe(ADDRESS_A); - expect(body.daoId).toBe(DAO_ID); - }); - - it("stores the author address in lowercase", async () => { - const id = crypto.randomUUID(); - const mixedCaseAddress = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - - const res = await app.request("/proposal/drafts", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - id, - address: mixedCaseAddress, - title: "", - body: "", - discussionUrl: "", - actions: [], - }), - }); - - expect(res.status).toBe(201); - const body = await res.json(); - expect(body.author).toBe(mixedCaseAddress.toLowerCase()); - }); - - it("returns 400 when id is not a valid UUID", async () => { - const res = await app.request("/proposal/drafts", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - id: "not-a-uuid", - address: ADDRESS_A, - title: "", - body: "", - discussionUrl: "", - actions: [], - }), - }); - expect(res.status).toBe(400); - }); - - it("returns 400 when required fields are missing", async () => { - const res = await app.request("/proposal/drafts", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - }); - }); - - describe("PUT /proposal/drafts/:id", () => { - it("updates a draft owned by the address", async () => { - const draft = makeDraft(); - await db.insert(proposalDrafts).values(draft); - - const res = await app.request(`/proposal/drafts/${draft.id}`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ address: ADDRESS_A, title: "Updated title" }), - }); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.title).toBe("Updated title"); - expect(body.id).toBe(draft.id); - }); - - it("returns 404 when address does not match the draft author", async () => { - const draft = makeDraft({ author: ADDRESS_A }); - await db.insert(proposalDrafts).values(draft); - - const res = await app.request(`/proposal/drafts/${draft.id}`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ address: ADDRESS_B, title: "Hijacked" }), - }); - - expect(res.status).toBe(404); - }); - - it("returns 404 when the draft does not exist", async () => { - const res = await app.request(`/proposal/drafts/${crypto.randomUUID()}`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ address: ADDRESS_A, title: "Ghost" }), - }); - expect(res.status).toBe(404); - }); - - it("returns 400 when address is missing from body", async () => { - const draft = makeDraft(); - await db.insert(proposalDrafts).values(draft); - - const res = await app.request(`/proposal/drafts/${draft.id}`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ title: "No address" }), - }); - expect(res.status).toBe(400); - }); - - it("returns 404 when updating a draft that belongs to a different dao", async () => { - const draft = makeDraft({ daoId: "other-dao" }); - await db.insert(proposalDrafts).values(draft); - - const res = await app.request(`/proposal/drafts/${draft.id}`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - address: ADDRESS_A, - title: "Cross-DAO hijack", - }), - }); - - expect(res.status).toBe(404); - - const stored = await db.query.proposalDrafts.findFirst({ - where: (table, { eq: eqOp }) => eqOp(table.id, draft.id), - }); - expect(stored?.title).toBe(draft.title); - }); - }); - - describe("DELETE /proposal/drafts/:id", () => { - it("deletes a draft owned by the address and returns 204", async () => { - const draft = makeDraft(); - await db.insert(proposalDrafts).values(draft); - - const res = await app.request( - `/proposal/drafts/${draft.id}?address=${ADDRESS_A}`, - { method: "DELETE" }, - ); - expect(res.status).toBe(204); - - const check = await app.request(`/proposal/drafts/${draft.id}`); - expect(check.status).toBe(404); - }); - - it("returns 404 when address does not match the draft author", async () => { - const draft = makeDraft({ author: ADDRESS_A }); - await db.insert(proposalDrafts).values(draft); - - const res = await app.request( - `/proposal/drafts/${draft.id}?address=${ADDRESS_B}`, - { method: "DELETE" }, - ); - expect(res.status).toBe(404); - }); - - it("returns 404 when the draft does not exist", async () => { - const res = await app.request( - `/proposal/drafts/${crypto.randomUUID()}?address=${ADDRESS_A}`, - { method: "DELETE" }, - ); - expect(res.status).toBe(404); - }); - - it("returns 400 when address query param is missing", async () => { - const draft = makeDraft(); - await db.insert(proposalDrafts).values(draft); - - const res = await app.request(`/proposal/drafts/${draft.id}`, { - method: "DELETE", - }); - expect(res.status).toBe(400); - }); - - it("returns 404 when deleting a draft that belongs to a different dao", async () => { - const draft = makeDraft({ daoId: "other-dao" }); - await db.insert(proposalDrafts).values(draft); - - const res = await app.request( - `/proposal/drafts/${draft.id}?address=${ADDRESS_A}`, - { method: "DELETE" }, - ); - - expect(res.status).toBe(404); - - const stored = await db.query.proposalDrafts.findFirst({ - where: (table, { eq: eqOp }) => eqOp(table.id, draft.id), - }); - expect(stored).toBeDefined(); - }); - }); -}); diff --git a/apps/api/src/controllers/draft-proposals/index.ts b/apps/api/src/controllers/draft-proposals/index.ts deleted file mode 100644 index 049857f15..000000000 --- a/apps/api/src/controllers/draft-proposals/index.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { OpenAPIHono as Hono, createRoute } from "@hono/zod-openapi"; - -import { - CreateDraftBodySchema, - DeleteDraftQuerySchema, - DraftListResponseSchema, - DraftParamsSchema, - DraftResponseSchema, - ListDraftsQuerySchema, - UpdateDraftBodySchema, -} from "@/mappers/draft-proposals"; -import { ErrorResponseSchema } from "@/mappers/shared"; -import type { DraftProposalsService } from "@/services/draft-proposals"; - -export function draftProposals( - app: Hono, - service: DraftProposalsService, - daoId: string, -) { - app.openapi( - createRoute({ - method: "get", - operationId: "getDraftProposals", - path: "/proposal/drafts", - summary: "List draft proposals for an address", - tags: ["proposals"], - request: { query: ListDraftsQuerySchema }, - responses: { - 200: { - description: "Draft proposals owned by the given address", - content: { "application/json": { schema: DraftListResponseSchema } }, - }, - }, - }), - async (c) => { - const { address } = c.req.valid("query"); - const drafts = await service.getDrafts(address, daoId); - return c.json(DraftListResponseSchema.parse({ items: drafts }), 200); - }, - ); - - app.openapi( - createRoute({ - method: "get", - operationId: "getDraftProposal", - path: "/proposal/drafts/{id}", - summary: "Get a draft proposal by ID", - description: - "Public endpoint — anyone with the ID can view the draft, enabling sharing.", - tags: ["proposals"], - request: { params: DraftParamsSchema }, - responses: { - 200: { - description: "The requested draft proposal", - content: { "application/json": { schema: DraftResponseSchema } }, - }, - 404: { - description: "Draft not found", - content: { "application/json": { schema: ErrorResponseSchema } }, - }, - }, - }), - async (c) => { - const { id } = c.req.valid("param"); - const draft = await service.getDraftById(id, daoId); - if (!draft) return c.json({ error: "Draft not found" }, 404); - return c.json(DraftResponseSchema.parse(draft), 200); - }, - ); - - app.openapi( - createRoute({ - method: "post", - operationId: "createDraftProposal", - path: "/proposal/drafts", - summary: "Create a draft proposal", - tags: ["proposals"], - request: { - body: { - content: { "application/json": { schema: CreateDraftBodySchema } }, - }, - }, - responses: { - 201: { - description: "The created draft proposal", - content: { "application/json": { schema: DraftResponseSchema } }, - }, - }, - }), - async (c) => { - const body = c.req.valid("json"); - const draft = await service.createDraft({ - id: body.id, - daoId, - author: body.address, - title: body.title, - discussionUrl: body.discussionUrl, - body: body.body, - actions: body.actions, - }); - return c.json(DraftResponseSchema.parse(draft), 201); - }, - ); - - app.openapi( - createRoute({ - method: "put", - operationId: "updateDraftProposal", - path: "/proposal/drafts/{id}", - summary: "Update a draft proposal", - description: - "Only the original author (matched by address) can update a draft.", - tags: ["proposals"], - request: { - params: DraftParamsSchema, - body: { - content: { "application/json": { schema: UpdateDraftBodySchema } }, - }, - }, - responses: { - 200: { - description: "The updated draft proposal", - content: { "application/json": { schema: DraftResponseSchema } }, - }, - 404: { - description: "Draft not found or address does not match author", - content: { "application/json": { schema: ErrorResponseSchema } }, - }, - }, - }), - async (c) => { - const { id } = c.req.valid("param"); - const { address, ...patch } = c.req.valid("json"); - const draft = await service.updateDraft(id, address, daoId, patch); - if (!draft) return c.json({ error: "Draft not found" }, 404); - return c.json(DraftResponseSchema.parse(draft), 200); - }, - ); - - app.openapi( - createRoute({ - method: "delete", - operationId: "deleteDraftProposal", - path: "/proposal/drafts/{id}", - summary: "Delete a draft proposal", - description: - "Only the original author (matched by address) can delete a draft.", - tags: ["proposals"], - request: { - params: DraftParamsSchema, - query: DeleteDraftQuerySchema, - }, - responses: { - 204: { description: "Draft deleted" }, - 404: { - description: "Draft not found or address does not match author", - content: { "application/json": { schema: ErrorResponseSchema } }, - }, - }, - }), - async (c) => { - const { id } = c.req.valid("param"); - const { address } = c.req.valid("query"); - const deleted = await service.deleteDraft(id, address, daoId); - if (!deleted) return c.json({ error: "Draft not found" }, 404); - return c.body(null, 204); - }, - ); -} diff --git a/apps/api/src/controllers/index.ts b/apps/api/src/controllers/index.ts index abc1c87c8..31ddf826f 100644 --- a/apps/api/src/controllers/index.ts +++ b/apps/api/src/controllers/index.ts @@ -1,5 +1,4 @@ export * from "./account-balance"; -export * from "./draft-proposals"; export * from "./delegation-percentage"; export * from "./governance-activity"; export * from "./health"; diff --git a/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts b/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts index 75b78cb0e..8c24d1028 100644 --- a/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts +++ b/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts @@ -4,7 +4,6 @@ import { pushSchema } from "drizzle-kit/api"; import { drizzle } from "drizzle-orm/pglite"; import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import type { UnifiedDrizzle } from "@/database"; -import * as generalSchema from "@/database/general-schema"; import * as schema from "@/database/schema"; import * as offchainSchema from "@/database/offchain-schema"; import { offchainProposals } from "@/database/offchain-schema"; @@ -72,7 +71,7 @@ const createApp = (supportOffchain = true) => { beforeAll(async () => { client = new PGlite(); - const unifiedSchema = { ...schema, ...offchainSchema, ...generalSchema }; + const unifiedSchema = { ...schema, ...offchainSchema }; db = drizzle(client, { schema: unifiedSchema }); /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const { apply } = await pushSchema(unifiedSchema, db as any); diff --git a/apps/api/src/controllers/votes/offchainVotes.integration.test.ts b/apps/api/src/controllers/votes/offchainVotes.integration.test.ts index 40526a2a2..8fdbbe6db 100644 --- a/apps/api/src/controllers/votes/offchainVotes.integration.test.ts +++ b/apps/api/src/controllers/votes/offchainVotes.integration.test.ts @@ -4,7 +4,6 @@ import { pushSchema } from "drizzle-kit/api"; import { drizzle } from "drizzle-orm/pglite"; import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import type { UnifiedDrizzle } from "@/database"; -import * as generalSchema from "@/database/general-schema"; import * as schema from "@/database/schema"; import * as offchainSchema from "@/database/offchain-schema"; import { offchainProposals, offchainVotes } from "@/database/offchain-schema"; @@ -74,7 +73,7 @@ const createApp = (supportOffchain = true) => { beforeAll(async () => { client = new PGlite(); - const unifiedSchema = { ...schema, ...offchainSchema, ...generalSchema }; + const unifiedSchema = { ...schema, ...offchainSchema }; db = drizzle(client, { schema: unifiedSchema }); /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const { apply } = await pushSchema(unifiedSchema, db as any); diff --git a/apps/api/src/database/general-schema.ts b/apps/api/src/database/general-schema.ts deleted file mode 100644 index bdcee3ac0..000000000 --- a/apps/api/src/database/general-schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { pgSchema, index, bigint } from "drizzle-orm/pg-core"; - -export const generalSchema = pgSchema("general"); - -export const proposalDrafts = generalSchema.table( - "proposal_drafts", - (d) => ({ - id: d.text().primaryKey(), - daoId: d.text("dao_id").notNull(), - author: d.text().notNull(), - title: d.text().notNull().default(""), - discussionUrl: d.text("discussion_url").notNull().default(""), - body: d.text().notNull().default(""), - actions: d.jsonb().$type().notNull().default([]), - createdAt: bigint("created_at", { mode: "bigint" }).notNull(), - updatedAt: bigint("updated_at", { mode: "bigint" }).notNull(), - }), - (table) => [index().on(table.author), index().on(table.daoId)], -); diff --git a/apps/api/src/database/index.ts b/apps/api/src/database/index.ts index 03e94c366..b3f51b894 100644 --- a/apps/api/src/database/index.ts +++ b/apps/api/src/database/index.ts @@ -1,7 +1,6 @@ import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { PgliteDatabase } from "drizzle-orm/pglite"; -import type * as generalSchema from "./general-schema"; import type * as offchainSchema from "./offchain-schema"; import type * as schema from "./schema"; @@ -23,14 +22,9 @@ export type OffchainDrizzle = | NodePgDatabase | PgliteDatabase; -export type GeneralDrizzle = - | NodePgDatabase - | PgliteDatabase; - export type UnifiedDrizzle = | NodePgDatabase | PgliteDatabase; export * from "./schema"; export * from "./offchain-schema"; -export * from "./general-schema"; diff --git a/apps/api/src/mappers/draft-proposals/index.ts b/apps/api/src/mappers/draft-proposals/index.ts deleted file mode 100644 index ed29420d0..000000000 --- a/apps/api/src/mappers/draft-proposals/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { z } from "@hono/zod-openapi"; - -import { AddressSchema } from "../shared"; - -const ActionSchema = z.record(z.string(), z.unknown()).openapi("DraftAction", { - description: "A single proposal action encoded as a JSON object.", -}); - -export const DraftResponseSchema = z - .object({ - id: z.string(), - daoId: z.string(), - author: z.string().openapi({ format: "ethereum-address" }), - title: z.string(), - discussionUrl: z.string(), - body: z.string(), - actions: z.array(ActionSchema), - createdAt: z - .bigint() - .transform((val) => val.toString()) - .openapi({ - type: "string", - format: "bigint", - description: - "Creation timestamp in Unix milliseconds, as a decimal string.", - }), - updatedAt: z - .bigint() - .transform((val) => val.toString()) - .openapi({ - type: "string", - format: "bigint", - description: - "Last-updated timestamp in Unix milliseconds, as a decimal string.", - }), - }) - .openapi("DraftProposal"); - -export type DraftResponse = z.infer; - -export const DraftListResponseSchema = z - .object({ items: z.array(DraftResponseSchema) }) - .openapi("DraftProposalList"); - -export const ListDraftsQuerySchema = z - .object({ - address: AddressSchema, - }) - .openapi("ListDraftsQuery", { - description: "Query parameters for listing draft proposals.", - }); - -export const DraftParamsSchema = z - .object({ - id: z.string().openapi({ description: "UUID of the draft." }), - }) - .openapi("DraftParams"); - -export const CreateDraftBodySchema = z - .object({ - id: z - .string() - .uuid() - .openapi({ description: "Client-generated UUID for the draft." }), - address: AddressSchema, - title: z.string().default(""), - discussionUrl: z.string().default(""), - body: z.string().default(""), - actions: z.array(ActionSchema).default([]), - }) - .openapi("CreateDraftBody"); - -export const UpdateDraftBodySchema = z - .object({ - address: AddressSchema, - title: z.string().optional(), - discussionUrl: z.string().optional(), - body: z.string().optional(), - actions: z.array(ActionSchema).optional(), - }) - .openapi("UpdateDraftBody"); - -export const DeleteDraftQuerySchema = z - .object({ - address: AddressSchema, - }) - .openapi("DeleteDraftQuery", { - description: "Query parameters for deleting a draft proposal.", - }); diff --git a/apps/api/src/mappers/index.ts b/apps/api/src/mappers/index.ts index ab6c5869d..236f4879e 100644 --- a/apps/api/src/mappers/index.ts +++ b/apps/api/src/mappers/index.ts @@ -15,4 +15,3 @@ export * from "./treasury"; export * from "./votes"; export * from "./voting-power"; export * from "./feed"; -export * from "./draft-proposals"; diff --git a/apps/api/src/repositories/draft-proposals/index.ts b/apps/api/src/repositories/draft-proposals/index.ts deleted file mode 100644 index c7fb550fc..000000000 --- a/apps/api/src/repositories/draft-proposals/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { and, desc, eq } from "drizzle-orm"; - -import { type GeneralDrizzle, proposalDrafts } from "@/database"; - -export type DBProposalDraft = typeof proposalDrafts.$inferSelect; -export type NewProposalDraft = typeof proposalDrafts.$inferInsert; - -export class DraftProposalsRepository { - constructor(private readonly db: GeneralDrizzle) {} - - async findByAuthorAndDao( - author: string, - daoId: string, - ): Promise { - return this.db - .select() - .from(proposalDrafts) - .where( - and( - eq(proposalDrafts.author, author.toLowerCase()), - eq(proposalDrafts.daoId, daoId), - ), - ) - .orderBy(desc(proposalDrafts.updatedAt)); - } - - async findById( - id: string, - daoId: string, - ): Promise { - return this.db.query.proposalDrafts.findFirst({ - where: and(eq(proposalDrafts.id, id), eq(proposalDrafts.daoId, daoId)), - }); - } - - async create(draft: NewProposalDraft): Promise { - const [created] = await this.db - .insert(proposalDrafts) - .values(draft) - .returning(); - return created!; - } - - async update( - id: string, - author: string, - daoId: string, - data: Partial< - Pick< - NewProposalDraft, - "title" | "discussionUrl" | "body" | "actions" | "updatedAt" - > - >, - ): Promise { - const [updated] = await this.db - .update(proposalDrafts) - .set(data) - .where( - and( - eq(proposalDrafts.id, id), - eq(proposalDrafts.author, author.toLowerCase()), - eq(proposalDrafts.daoId, daoId), - ), - ) - .returning(); - return updated; - } - - async delete(id: string, author: string, daoId: string): Promise { - const result = await this.db - .delete(proposalDrafts) - .where( - and( - eq(proposalDrafts.id, id), - eq(proposalDrafts.author, author.toLowerCase()), - eq(proposalDrafts.daoId, daoId), - ), - ) - .returning(); - return result.length > 0; - } -} diff --git a/apps/api/src/repositories/index.ts b/apps/api/src/repositories/index.ts index c562b30c0..820e3ce1d 100644 --- a/apps/api/src/repositories/index.ts +++ b/apps/api/src/repositories/index.ts @@ -1,5 +1,4 @@ export * from "./daoMetricsDayBucket"; -export * from "./draft-proposals"; export * from "./last-update"; export * from "./drizzle"; export * from "./proposals-activity"; diff --git a/apps/api/src/repositories/proposals/offchainProposals.unit.test.ts b/apps/api/src/repositories/proposals/offchainProposals.unit.test.ts index b5ea4ec46..fd1074f86 100644 --- a/apps/api/src/repositories/proposals/offchainProposals.unit.test.ts +++ b/apps/api/src/repositories/proposals/offchainProposals.unit.test.ts @@ -3,7 +3,6 @@ import { pushSchema } from "drizzle-kit/api"; import { drizzle } from "drizzle-orm/pglite"; import type { UnifiedDrizzle } from "@/database"; -import * as generalSchema from "@/database/general-schema"; import * as schema from "@/database/schema"; import * as offchainSchema from "@/database/offchain-schema"; import { offchainProposals } from "@/database/offchain-schema"; @@ -39,7 +38,7 @@ describe("OffchainProposalRepository", () => { beforeAll(async () => { client = new PGlite(); - const unifiedSchema = { ...schema, ...offchainSchema, ...generalSchema }; + const unifiedSchema = { ...schema, ...offchainSchema }; db = drizzle(client, { schema: unifiedSchema }); repository = new OffchainProposalRepository(db); diff --git a/apps/api/src/repositories/votes/offchainNonVoters.unit.test.ts b/apps/api/src/repositories/votes/offchainNonVoters.unit.test.ts index 45ffc6197..1f4752f7d 100644 --- a/apps/api/src/repositories/votes/offchainNonVoters.unit.test.ts +++ b/apps/api/src/repositories/votes/offchainNonVoters.unit.test.ts @@ -5,7 +5,6 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import { getAddress } from "viem"; import type { UnifiedDrizzle } from "@/database"; -import * as generalSchema from "@/database/general-schema"; import * as offchainSchema from "@/database/offchain-schema"; import * as schema from "@/database/schema"; import { accountPower } from "@/database/schema"; @@ -75,7 +74,7 @@ describe("OffchainNonVotersRepositoryImpl", () => { beforeAll(async () => { client = new PGlite(); - const combinedSchema = { ...schema, ...offchainSchema, ...generalSchema }; + const combinedSchema = { ...schema, ...offchainSchema }; db = drizzle(client, { schema: combinedSchema }); diff --git a/apps/api/src/repositories/votes/offchainVotes.unit.test.ts b/apps/api/src/repositories/votes/offchainVotes.unit.test.ts index 9df6f18be..e94c0bf4b 100644 --- a/apps/api/src/repositories/votes/offchainVotes.unit.test.ts +++ b/apps/api/src/repositories/votes/offchainVotes.unit.test.ts @@ -3,7 +3,6 @@ import { pushSchema } from "drizzle-kit/api"; import { drizzle } from "drizzle-orm/pglite"; import type { UnifiedDrizzle } from "@/database"; -import * as generalSchema from "@/database/general-schema"; import * as schema from "@/database/schema"; import * as offchainSchema from "@/database/offchain-schema"; import { offchainProposals, offchainVotes } from "@/database/offchain-schema"; @@ -56,7 +55,7 @@ describe("OffchainVoteRepository", () => { beforeAll(async () => { client = new PGlite(); - const unifiedSchema = { ...schema, ...offchainSchema, ...generalSchema }; + const unifiedSchema = { ...schema, ...offchainSchema }; db = drizzle(client, { schema: unifiedSchema }); repository = new OffchainVoteRepository(db); diff --git a/apps/api/src/services/draft-proposals/index.ts b/apps/api/src/services/draft-proposals/index.ts deleted file mode 100644 index a4eb53154..000000000 --- a/apps/api/src/services/draft-proposals/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { - DBProposalDraft, - DraftProposalsRepository, -} from "@/repositories/draft-proposals"; - -export type CreateDraftInput = { - id: string; - daoId: string; - author: string; - title: string; - discussionUrl: string; - body: string; - actions: unknown[]; -}; - -export type UpdateDraftInput = { - title?: string; - discussionUrl?: string; - body?: string; - actions?: unknown[]; -}; - -export class DraftProposalsService { - constructor(private readonly repo: DraftProposalsRepository) {} - - async getDrafts(author: string, daoId: string): Promise { - return this.repo.findByAuthorAndDao(author, daoId); - } - - async getDraftById( - id: string, - daoId: string, - ): Promise { - return this.repo.findById(id, daoId); - } - - async createDraft(input: CreateDraftInput): Promise { - const now = BigInt(Date.now()); - return this.repo.create({ - id: input.id, - daoId: input.daoId, - author: input.author.toLowerCase(), - title: input.title, - discussionUrl: input.discussionUrl, - body: input.body, - actions: input.actions, - createdAt: now, - updatedAt: now, - }); - } - - async updateDraft( - id: string, - author: string, - daoId: string, - input: UpdateDraftInput, - ): Promise { - return this.repo.update(id, author, daoId, { - ...input, - updatedAt: BigInt(Date.now()), - }); - } - - async deleteDraft( - id: string, - author: string, - daoId: string, - ): Promise { - return this.repo.delete(id, author, daoId); - } -} diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index 34f4829ea..d9461a003 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -1,5 +1,4 @@ export * from "./delegation-percentage"; -export * from "./draft-proposals"; export * from "./token-metrics"; export * from "./voting-power"; export * from "./coingecko"; From 0502116b7c2572af3476a6e51fc7bba2c2d197b7 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 10 Jul 2026 07:55:28 -0300 Subject: [PATCH 2/2] feat(user-api): one-shot draft migration script (DAO db -> User db) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copies general.proposal_drafts rows from a DAO database into the User API drafts table, preserving ids (share links keep resolving) and timestamps, lowercasing the author into author_address, and leaving user_id NULL so the row is claimed on that wallet's first SIWE login. Idempotent via ON CONFLICT DO NOTHING (also resolves cross-DAO id collisions — first writer wins), validates rows, and supports --dry-run. Run once per DAO source db. Co-Authored-By: Claude Fable 5 --- apps/user-api/package.json | 3 +- apps/user-api/scripts/migrate-drafts.test.ts | 139 +++++++++++++++ apps/user-api/scripts/migrate-drafts.ts | 168 +++++++++++++++++++ 3 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 apps/user-api/scripts/migrate-drafts.test.ts create mode 100644 apps/user-api/scripts/migrate-drafts.ts diff --git a/apps/user-api/package.json b/apps/user-api/package.json index e4a05d532..b91a6f58d 100644 --- a/apps/user-api/package.json +++ b/apps/user-api/package.json @@ -18,7 +18,8 @@ "auth:generate": "better-auth generate --config ./src/auth.ts --output ./src/database/auth-schema.ts --yes", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:push": "drizzle-kit push" + "db:push": "drizzle-kit push", + "migrate:drafts": "tsx scripts/migrate-drafts.ts" }, "keywords": [], "author": "", diff --git a/apps/user-api/scripts/migrate-drafts.test.ts b/apps/user-api/scripts/migrate-drafts.test.ts new file mode 100644 index 000000000..66c46b243 --- /dev/null +++ b/apps/user-api/scripts/migrate-drafts.test.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { PGlite } from "@electric-sql/pglite"; +import { pushSchema } from "drizzle-kit/api"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import * as fullSchema from "@/database/schema"; +import { + account, + drafts, + session, + user, + verification, + walletAddress, +} from "@/database/schema"; +import { DraftsRepository } from "@/repositories/drafts"; +import { DraftsService } from "@/services/drafts"; +import { migrateDrafts, type SourceDraft } from "./migrate-drafts"; + +const ADDR = "0xAbC0000000000000000000000000000000000001"; + +const source = (over: Partial = {}): SourceDraft => ({ + id: crypto.randomUUID(), + daoId: "ens", + author: ADDR, + title: "migrated", + discussionUrl: "", + body: "body", + actions: [], + createdAt: 1700000000000, + updatedAt: 1700000000001, + ...over, +}); + +describe("migrateDrafts", () => { + const client = new PGlite(); + const db = drizzle(client, { schema: fullSchema }); + + beforeEach(async () => { + const tables = { + user, + session, + account, + verification, + walletAddress, + drafts, + }; + const { apply } = await pushSchema(tables as any, db as any); + await apply(); + await db.delete(drafts); + }); + + afterAll(async () => { + await client.close(); + }); + + it("copies rows unclaimed, preserving id/timestamps and lowercasing author", async () => { + const row = source(); + const report = await migrateDrafts([row], db); + + expect(report).toEqual({ read: 1, inserted: 1, skipped: 0, invalid: 0 }); + const [stored] = await db + .select() + .from(drafts) + .where(eq(drafts.id, row.id)); + expect(stored).toMatchObject({ + id: row.id, + userId: null, + authorAddress: ADDR.toLowerCase(), + daoId: "ens", + createdAt: 1700000000000, + updatedAt: 1700000000001, + }); + }); + + it("is idempotent — a second run skips existing ids", async () => { + const rows = [source(), source()]; + await migrateDrafts(rows, db); + const second = await migrateDrafts(rows, db); + + expect(second).toEqual({ read: 2, inserted: 0, skipped: 2, invalid: 0 }); + expect(await db.$count(drafts)).toBe(2); + }); + + it("skips malformed rows without aborting the batch", async () => { + const rows = [ + source(), + source({ id: "not-a-uuid" }), + source({ author: "0xnothex" }), + source({ daoId: "" }), + ]; + const report = await migrateDrafts(rows, db); + + expect(report).toEqual({ read: 4, inserted: 1, skipped: 0, invalid: 3 }); + expect(await db.$count(drafts)).toBe(1); + }); + + it("dry-run writes nothing", async () => { + const report = await migrateDrafts([source(), source()], db, { + dryRun: true, + }); + expect(report.inserted).toBe(0); + expect(await db.$count(drafts)).toBe(0); + }); + + it("migrated rows are claimed on the author's first login", async () => { + const row = source(); + await migrateDrafts([row], db); + + // Simulate that wallet having signed in: a user + linked walletAddress. + const [u] = await db + .insert(user) + .values({ + id: crypto.randomUUID(), + name: "u", + email: `${ADDR.toLowerCase()}@wallet.local`, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + await db.insert(walletAddress).values({ + id: crypto.randomUUID(), + userId: u!.id, + address: ADDR.toLowerCase(), + chainId: 1, + isPrimary: true, + createdAt: new Date(), + }); + + const service = new DraftsService(new DraftsRepository(db)); + const list = await service.listForUser(u!.id, "ens"); + + expect(list).toHaveLength(1); + expect(list[0]!.id).toBe(row.id); + expect(list[0]!.userId).toBe(u!.id); + }); +}); diff --git a/apps/user-api/scripts/migrate-drafts.ts b/apps/user-api/scripts/migrate-drafts.ts new file mode 100644 index 000000000..ae03e1f28 --- /dev/null +++ b/apps/user-api/scripts/migrate-drafts.ts @@ -0,0 +1,168 @@ +/** + * One-shot migration: copies draft proposals from a DAO API's Postgres + * (`general.proposal_drafts`) into the User API's `drafts` table. + * + * Run once per DAO source database (each DAO API has its own): + * + * SOURCE_DATABASE_URL=postgres://…dao-db DATABASE_URL=postgres://…user-db \ + * pnpm --filter @anticapture/user-api migrate:drafts [--dry-run] + * + * Migrated rows land unclaimed (`user_id NULL`) with the original author + * wallet in `author_address`; the User API claims them onto a user on that + * wallet's first SIWE login (DraftsService.listForUser). Draft ids are + * preserved verbatim so existing share links keep resolving. Idempotent: + * re-running skips ids already present (also how cross-DAO id collisions are + * handled — first writer wins, the rest are logged as skipped). + */ +import { fileURLToPath } from "node:url"; + +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +import * as schema from "@/database/schema"; +import { drafts } from "@/database/schema"; +import type { UserApiDrizzle } from "@/database/types"; + +export type SourceDraft = { + id: string; + daoId: string; + author: string; + title: string; + discussionUrl: string; + body: string; + actions: unknown[]; + createdAt: number; + updatedAt: number; +}; + +export type MigrationReport = { + read: number; + inserted: number; + skipped: number; + invalid: number; +}; + +const isUuid = (v: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); + +const isAddress = (v: string) => /^0x[0-9a-fA-F]{40}$/.test(v); + +/** Imported rows are untrusted DB content — reject anything malformed. */ +const isValid = (row: SourceDraft): boolean => + isUuid(row.id) && + row.daoId.length > 0 && + isAddress(row.author) && + Number.isFinite(row.createdAt) && + Number.isFinite(row.updatedAt); + +/** + * Pure core: inserts the given source rows into the User API `drafts` table, + * skipping invalid rows and ids that already exist. Separated from the CLI so + * it can be exercised against an in-memory DB in tests. + */ +export async function migrateDrafts( + rows: SourceDraft[], + db: UserApiDrizzle, + opts: { dryRun?: boolean } = {}, +): Promise { + const report: MigrationReport = { + read: rows.length, + inserted: 0, + skipped: 0, + invalid: 0, + }; + + for (const row of rows) { + if (!isValid(row)) { + report.invalid++; + continue; + } + if (opts.dryRun) continue; + + const inserted = await db + .insert(drafts) + .values({ + id: row.id, + userId: null, + authorAddress: row.author.toLowerCase(), + daoId: row.daoId, + title: row.title, + discussionUrl: row.discussionUrl, + body: row.body, + actions: row.actions, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }) + .onConflictDoNothing() + .returning(); + + if (inserted.length > 0) report.inserted++; + else report.skipped++; + } + + return report; +} + +/** Reads `general.proposal_drafts` from a DAO source database. */ +async function readSource(sourceUrl: string): Promise { + const pool = new Pool({ connectionString: sourceUrl }); + try { + const { rows } = await pool.query( + `SELECT id, dao_id, author, title, discussion_url, body, actions, + created_at, updated_at + FROM general.proposal_drafts`, + ); + return rows.map((r) => ({ + id: r.id, + daoId: r.dao_id, + author: r.author, + title: r.title, + discussionUrl: r.discussion_url, + body: r.body, + actions: (r.actions ?? []) as unknown[], + // bigint columns arrive as strings via node-postgres. + createdAt: Number(r.created_at), + updatedAt: Number(r.updated_at), + })); + } finally { + await pool.end(); + } +} + +async function main() { + const sourceUrl = process.env.SOURCE_DATABASE_URL; + const destUrl = process.env.DATABASE_URL; + const dryRun = process.argv.includes("--dry-run"); + + if (!sourceUrl || !destUrl) { + console.error( + "SOURCE_DATABASE_URL (DAO db) and DATABASE_URL (user-api db) are required", + ); + process.exit(1); + } + + const rows = await readSource(sourceUrl); + const db = drizzle(destUrl, { schema }); + + // Fail loud if the destination table is missing (wrong DATABASE_URL). + await db.execute(sql`select 1 from drafts limit 1`).catch((err) => { + console.error( + "destination `drafts` table not reachable — check DATABASE_URL", + ); + throw err; + }); + + const report = await migrateDrafts(rows, db, { dryRun }); + console.error( + `${dryRun ? "[dry-run] " : ""}drafts migration: ` + + `read=${report.read} inserted=${report.inserted} ` + + `skipped=${report.skipped} invalid=${report.invalid}`, + ); + process.exit(0); +} + +// Run only when invoked directly, not when imported by tests. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + void main(); +}