diff --git a/.changeset/draft-proposals-db.md b/.changeset/draft-proposals-db.md new file mode 100644 index 000000000..5497e451e --- /dev/null +++ b/.changeset/draft-proposals-db.md @@ -0,0 +1,10 @@ +--- +"@anticapture/api": minor +"@anticapture/dashboard": minor +"@anticapture/api-gateway": patch +"@anticapture/gateful": patch +--- + +feat(draft-proposals): persist draft proposals in PostgreSQL with SIWE authentication + +Moves draft proposal storage from browser localStorage to the API's PostgreSQL database. Adds SIWE-based JWT authentication endpoints (`GET /auth/nonce`, `POST /auth/verify`) and full CRUD endpoints for draft proposals (`/proposal-drafts`). On wallet connect, existing localStorage drafts are automatically migrated to the database. Drafts are scoped per user address and DAO. diff --git a/apps/api/cmd/index.ts b/apps/api/cmd/index.ts index 507a19e4c..117ce0151 100644 --- a/apps/api/cmd/index.ts +++ b/apps/api/cmd/index.ts @@ -16,6 +16,7 @@ import { accountInteractions, dao, delegationPercentage, + draftProposals, governanceActivity, historicalBalances, historicalVotingPower, @@ -42,6 +43,7 @@ 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"; @@ -58,6 +60,7 @@ import { AccountInteractionsRepository, BalanceVariationsRepository, DaoMetricsDayBucketRepository, + DraftProposalsRepository, DrizzleProposalsActivityRepository, DrizzleRepository, HealthRepositoryImpl, @@ -84,6 +87,7 @@ import { CoingeckoService, DaoService, DelegationPercentageService, + DraftProposalsService, HealthService, HistoricalBalancesService, NFTPriceService, @@ -180,6 +184,11 @@ const pgClient = drizzle(env.DATABASE_URL, { casing: "snake_case", }); +const pgGeneralClient = drizzle(env.DATABASE_URL, { + schema: generalSchema, + casing: "snake_case", +}); + health(app, new HealthService(new HealthRepositoryImpl(pgClient), daoClient)); const daoConfig = CONTRACT_ADDRESSES[env.DAO_ID]; @@ -344,6 +353,15 @@ 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 new file mode 100644 index 000000000..916b77902 --- /dev/null +++ b/apps/api/drizzle.config.ts @@ -0,0 +1,11 @@ +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 new file mode 100644 index 000000000..50b4e7aba --- /dev/null +++ b/apps/api/drizzle/0000_large_mac_gargan.sql @@ -0,0 +1,16 @@ +CREATE SCHEMA "general"; +--> statement-breakpoint +CREATE TABLE "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 "proposal_drafts_author_index" ON "general"."proposal_drafts" USING btree ("author");--> statement-breakpoint +CREATE INDEX "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 new file mode 100644 index 000000000..8485c0709 --- /dev/null +++ b/apps/api/drizzle/meta/0000_snapshot.json @@ -0,0 +1,123 @@ +{ + "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 new file mode 100644 index 000000000..e14c12ebe --- /dev/null +++ b/apps/api/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "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 effdbde9e..0f2d89379 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,7 +16,10 @@ "build:watch": "tsc --watch", "lint": "eslint src", "lint:fix": "eslint src --fix", - "dev": "tsx watch cmd/index.ts" + "dev": "tsx watch cmd/index.ts", + "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate" }, "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 new file mode 100644 index 000000000..1668c633a --- /dev/null +++ b/apps/api/src/controllers/draft-proposals/draft-proposals.integration.test.ts @@ -0,0 +1,325 @@ +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 new file mode 100644 index 000000000..48bb32dfb --- /dev/null +++ b/apps/api/src/controllers/draft-proposals/index.ts @@ -0,0 +1,169 @@ +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: ["draft-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: ["draft-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: ["draft-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: ["draft-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: ["draft-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 31ddf826f..abc1c87c8 100644 --- a/apps/api/src/controllers/index.ts +++ b/apps/api/src/controllers/index.ts @@ -1,4 +1,5 @@ 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 ff12a1534..d1e740584 100644 --- a/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts +++ b/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts @@ -4,6 +4,7 @@ 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"; @@ -63,7 +64,7 @@ const BASE_PROPOSAL_ITEM = { beforeAll(async () => { client = new PGlite(); - const unifiedSchema = { ...schema, ...offchainSchema }; + const unifiedSchema = { ...schema, ...offchainSchema, ...generalSchema }; 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 5869ef13f..caef2a131 100644 --- a/apps/api/src/controllers/votes/offchainVotes.integration.test.ts +++ b/apps/api/src/controllers/votes/offchainVotes.integration.test.ts @@ -4,6 +4,7 @@ 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"; @@ -65,7 +66,7 @@ const createOffchainProposal = ( beforeAll(async () => { client = new PGlite(); - const unifiedSchema = { ...schema, ...offchainSchema }; + const unifiedSchema = { ...schema, ...offchainSchema, ...generalSchema }; 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 new file mode 100644 index 000000000..bdcee3ac0 --- /dev/null +++ b/apps/api/src/database/general-schema.ts @@ -0,0 +1,19 @@ +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 c532ade85..03e94c366 100644 --- a/apps/api/src/database/index.ts +++ b/apps/api/src/database/index.ts @@ -1,25 +1,14 @@ 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"; -/** - * Full Drizzle database type with write capabilities - * This follows Ponder's Drizzle type definition pattern from: - * node_modules/ponder/src/types/db.ts - * - * Supports: - * - NodePgDatabase: PostgreSQL via node-postgres driver - * - PgliteDatabase: PGlite embedded PostgreSQL - */ export type Drizzle = | NodePgDatabase | PgliteDatabase; -/** - * Read-only Drizzle database type (used in Ponder API context) - * Omits write operations: insert, update, delete, transaction - */ + export type ReadonlyDrizzle = Omit< Drizzle, | "insert" @@ -34,13 +23,14 @@ export type OffchainDrizzle = | NodePgDatabase | PgliteDatabase; -/** - * Unified Drizzle type that can query both public and snapshot schemas. - * Used for cross-schema queries (e.g., offchain non-voters). - */ +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 new file mode 100644 index 000000000..ed29420d0 --- /dev/null +++ b/apps/api/src/mappers/draft-proposals/index.ts @@ -0,0 +1,89 @@ +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 236f4879e..ab6c5869d 100644 --- a/apps/api/src/mappers/index.ts +++ b/apps/api/src/mappers/index.ts @@ -15,3 +15,4 @@ 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 new file mode 100644 index 000000000..c7fb550fc --- /dev/null +++ b/apps/api/src/repositories/draft-proposals/index.ts @@ -0,0 +1,82 @@ +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 820e3ce1d..c562b30c0 100644 --- a/apps/api/src/repositories/index.ts +++ b/apps/api/src/repositories/index.ts @@ -1,4 +1,5 @@ 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 fd1074f86..b5ea4ec46 100644 --- a/apps/api/src/repositories/proposals/offchainProposals.unit.test.ts +++ b/apps/api/src/repositories/proposals/offchainProposals.unit.test.ts @@ -3,6 +3,7 @@ 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"; @@ -38,7 +39,7 @@ describe("OffchainProposalRepository", () => { beforeAll(async () => { client = new PGlite(); - const unifiedSchema = { ...schema, ...offchainSchema }; + const unifiedSchema = { ...schema, ...offchainSchema, ...generalSchema }; 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 1f4752f7d..45ffc6197 100644 --- a/apps/api/src/repositories/votes/offchainNonVoters.unit.test.ts +++ b/apps/api/src/repositories/votes/offchainNonVoters.unit.test.ts @@ -5,6 +5,7 @@ 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"; @@ -74,7 +75,7 @@ describe("OffchainNonVotersRepositoryImpl", () => { beforeAll(async () => { client = new PGlite(); - const combinedSchema = { ...schema, ...offchainSchema }; + const combinedSchema = { ...schema, ...offchainSchema, ...generalSchema }; 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 e94c0bf4b..9df6f18be 100644 --- a/apps/api/src/repositories/votes/offchainVotes.unit.test.ts +++ b/apps/api/src/repositories/votes/offchainVotes.unit.test.ts @@ -3,6 +3,7 @@ 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"; @@ -55,7 +56,7 @@ describe("OffchainVoteRepository", () => { beforeAll(async () => { client = new PGlite(); - const unifiedSchema = { ...schema, ...offchainSchema }; + const unifiedSchema = { ...schema, ...offchainSchema, ...generalSchema }; 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 new file mode 100644 index 000000000..a4eb53154 --- /dev/null +++ b/apps/api/src/services/draft-proposals/index.ts @@ -0,0 +1,71 @@ +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 d9461a003..34f4829ea 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -1,4 +1,5 @@ export * from "./delegation-percentage"; +export * from "./draft-proposals"; export * from "./token-metrics"; export * from "./voting-power"; export * from "./coingecko"; diff --git a/apps/dashboard/features/create-proposal/components/BodyField.tsx b/apps/dashboard/features/create-proposal/components/BodyField.tsx index 972f0bc0e..08d36a0cd 100644 --- a/apps/dashboard/features/create-proposal/components/BodyField.tsx +++ b/apps/dashboard/features/create-proposal/components/BodyField.tsx @@ -52,7 +52,11 @@ import type { ProposalFormValues } from "@/features/create-proposal/schema"; type Mode = "visual" | "markdown"; -export const BodyField = () => { +type BodyFieldProps = { + version?: number; +}; + +export const BodyField = ({ version = 0 }: BodyFieldProps) => { const { control, watch } = useFormContext(); const body = watch("body") ?? ""; const [mode, setMode] = useState("visual"); @@ -106,7 +110,7 @@ export const BodyField = () => { name="body" render={({ field }) => ( field.onChange(md)} contentEditableClassName="!min-h-75 md:!min-h-130 max-w-none px-4 py-4 focus:outline-none" diff --git a/apps/dashboard/features/create-proposal/components/ProposalCreationForm.tsx b/apps/dashboard/features/create-proposal/components/ProposalCreationForm.tsx index dbc1cfa55..e7f728e1f 100644 --- a/apps/dashboard/features/create-proposal/components/ProposalCreationForm.tsx +++ b/apps/dashboard/features/create-proposal/components/ProposalCreationForm.tsx @@ -20,7 +20,10 @@ import { formatNumberUserReadable } from "@/shared/utils/formatNumberUserReadabl import { getWhitelabelBasePath } from "@/shared/utils/whitelabel"; import { FormLabel } from "@/shared/components/design-system/form/fields/form-label/FormLabel"; import { Input } from "@/shared/components/design-system/form/fields/input/Input"; +import { getDraftProposal } from "@anticapture/client"; +import type { GetDraftProposalPathParamsDaoEnumKey } from "@anticapture/client"; import { showCustomToast } from "@/features/governance/utils/showCustomToast"; +import { copyDraftShareUrl } from "@/features/create-proposal/utils/draftShareUrl"; import { BODY_PLACEHOLDER } from "@/features/create-proposal/constants"; import { ProposalFormSchema, @@ -88,7 +91,7 @@ export const ProposalCreationForm = ({ const searchParams = useSearchParams(); const draftId = searchParams?.get("draftId") ?? undefined; const { address } = useAccount(); - const drafts = useDrafts(daoId, address); + const drafts = useDrafts(daoId); const vp = useProposalVotingPower(daoId, address || zeroAddress); @@ -104,26 +107,72 @@ export const ProposalCreationForm = ({ defaultValues: DEFAULTS, mode: "onChange", }); - const hasHydratedDraftRef = useRef(false); + const hydratedDraftIdRef = useRef(undefined); useEffect(() => { if (!draftId) return; - if (hasHydratedDraftRef.current) return; + if (hydratedDraftIdRef.current === draftId) return; + const d = drafts.getDraft(draftId); - if (!d) return; - form.reset({ - title: d.title, - discussionUrl: d.discussionUrl, - body: d.body, - actions: d.actions.map(toFormAction), - }); - hasHydratedDraftRef.current = true; + if (d) { + form.reset({ + title: d.title, + discussionUrl: d.discussionUrl, + body: d.body, + actions: d.actions.map(toFormAction), + }); + setCurrentDraftId(draftId); + setBodyVersion((v) => v + 1); + hydratedDraftIdRef.current = draftId; + return; + } + + if (drafts.isLoading) return; + + // Cancellation flag so a stale shared-draft response from a previous + // draftId cannot overwrite the form after in-app navigation between + // shared links resolves out of order. + let cancelled = false; + + // Mark the guard inside the success/explicit-miss branches so a transient + // fetch failure does not permanently suppress later effect runs (e.g. + // when `drafts.drafts` or `drafts.isLoading` change after a retry). + void getDraftProposal( + daoId as GetDraftProposalPathParamsDaoEnumKey, + draftId, + ) + .then((shared) => { + if (cancelled) return; + hydratedDraftIdRef.current = draftId; + if (!shared) return; + form.reset({ + title: shared.title, + discussionUrl: shared.discussionUrl, + body: shared.body, + actions: shared.actions.map((a) => toFormAction(a as ProposalAction)), + }); + if (address && shared.author.toLowerCase() === address.toLowerCase()) { + setCurrentDraftId(draftId); + } else { + setCurrentDraftId(undefined); + } + setBodyVersion((v) => v + 1); + }) + .catch(() => { + if (cancelled) return; + showCustomToast("Could not load the shared draft", "error"); + }); + + return () => { + cancelled = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [draftId, drafts.drafts]); + }, [draftId, drafts.drafts, drafts.isLoading]); const [currentDraftId, setCurrentDraftId] = useState( draftId, ); + const [bodyVersion, setBodyVersion] = useState(0); const [transferOpen, setTransferOpen] = useState(false); const [customOpen, setCustomOpen] = useState(false); const [editActionIndex, setEditActionIndex] = useState(null); @@ -134,6 +183,10 @@ export const ProposalCreationForm = ({ const [submittedOpen, setSubmittedOpen] = useState(false); const [failedOpen, setFailedOpen] = useState(false); const [insufficientOpen, setInsufficientOpen] = useState(false); + const [isSavingDraft, setIsSavingDraft] = useState(false); + // Mirror in a ref so repeated synchronous clicks (before React commits the + // state update) can still see an in-flight save and short-circuit early. + const isSavingDraftRef = useRef(false); const values = form.watch(); const hasTitle = Boolean(values.title); @@ -152,44 +205,65 @@ export const ProposalCreationForm = ({ form.formState.isValid && (values.body?.length ?? 0) <= 10_000; - const handleSaveDraft = (options?: { navigateToDrafts?: boolean }) => { + const handleShare = async () => { + if (!currentDraftId) return; + const copied = await copyDraftShareUrl(basePath, currentDraftId); + if (copied) { + showCustomToast("Share link copied", "success"); + } else { + showCustomToast("Could not copy link", "error"); + } + }; + + const handleSaveDraft = async (options?: { navigateToDrafts?: boolean }) => { if (!address) { showCustomToast("Connect a wallet to save drafts", "error"); return; } - const id = drafts.saveDraft( - { - daoId, - title: values.title, - discussionUrl: values.discussionUrl ?? "", - body: values.body, - actions: values.actions, - }, - currentDraftId, - ); - if (!id) { + // Guard against duplicate creates: while a save is in flight, + // `currentDraftId` hasn't been set yet, so a second click would create + // another draft. Short-circuit until the first save settles. + if (isSavingDraftRef.current) return; + isSavingDraftRef.current = true; + setIsSavingDraft(true); + try { + const id = await drafts.saveDraft( + { + daoId, + title: values.title, + discussionUrl: values.discussionUrl ?? "", + body: values.body, + actions: values.actions, + }, + currentDraftId, + ); + if (!id) { + showCustomToast("Could not save draft", "error"); + return; + } + setCurrentDraftId(id); + form.reset(values, { keepValues: true, keepDirty: false }); + showCustomToast("Draft saved", "success"); + if (options?.navigateToDrafts !== false) { + router.push(`${basePath}/proposals?tab=drafts`); + } + } catch { showCustomToast("Could not save draft", "error"); - return; - } - setCurrentDraftId(id); - form.reset(values, { keepValues: true, keepDirty: false }); - showCustomToast("Draft saved", "success"); - if (options?.navigateToDrafts !== false) { - router.push(`${basePath}/proposals?tab=drafts`); + } finally { + isSavingDraftRef.current = false; + setIsSavingDraft(false); } }; const handlePublishClick = () => { if (vp.votingPower < threshold) { - handleSaveDraft({ navigateToDrafts: false }); + void handleSaveDraft({ navigateToDrafts: false }); setInsufficientOpen(true); return; } if (vp.isLoading) { showCustomToast( - vp.isLoading - ? "Still checking your voting power — try again in a moment." - : "Couldn't verify your voting power. Try again in a moment.", + "Still checking your voting power — try again in a moment.", "error", ); return; @@ -375,7 +449,7 @@ export const ProposalCreationForm = ({
Description - +
@@ -406,6 +480,8 @@ export const ProposalCreationForm = ({ canPublish={canPublish} onSaveDraft={handleSaveDraft} onPublish={handlePublishClick} + onShare={currentDraftId ? handleShare : undefined} + isSavingDraft={isSavingDraft} /> void; + onSaveDraft: () => Promise; onPublish: () => void; isSavingDraft?: boolean; + onShare?: () => void; } export const ProposalFormNavBar = ({ @@ -21,6 +22,7 @@ export const ProposalFormNavBar = ({ onSaveDraft, onPublish, isSavingDraft = false, + onShare, }: ProposalFormNavBarProps) => { const percent = (filledCount / totalCount) * 100; return ( @@ -32,6 +34,12 @@ export const ProposalFormNavBar = ({ className="max-w-xs flex-1" />
+ {onShare && ( + + )}
); - if (error) { + if (error && activeTab !== "drafts") { return (
{
{activeTab === "drafts" && isConnected ? ( <> - {drafts.length === 0 ? ( + {isDraftsLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : draftsError && drafts.length === 0 ? ( +
+ + +
+ ) : drafts.length === 0 ? ( ) : (
+ {draftsError && ( +
+ + Couldn't sync drafts with the server. Showing local + copies. + + +
+ )} {drafts.map((draft) => ( { router.push(`${basePath}/proposals/new?draftId=${id}`) } onDelete={(id) => setDraftToDelete(id)} + onShare={async (id) => { + const copied = await copyDraftShareUrl(basePath, id); + if (copied) { + showCustomToast("Share link copied", "success"); + } else { + showCustomToast("Could not copy link", "error"); + } + }} /> ))}
@@ -347,10 +390,15 @@ export const GovernanceSection = () => { onOpenChange={(open) => { if (!open) setDraftToDelete(null); }} - onConfirm={() => { + onConfirm={async () => { if (draftToDelete !== null) { - deleteDraft(draftToDelete); + const id = draftToDelete; setDraftToDelete(null); + try { + await deleteDraft(id); + } catch { + showCustomToast("Could not delete draft", "error"); + } } }} /> @@ -465,6 +513,23 @@ const ProposalListSection = ({ ); }; +const DraftCardSkeleton = () => ( +
+
+
+ + +
+ +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+); + const ProposalItemSkeleton = () => { return (
diff --git a/apps/gateful/openapi/gateful.json b/apps/gateful/openapi/gateful.json index 7b66783bb..c7d2ca39b 100644 --- a/apps/gateful/openapi/gateful.json +++ b/apps/gateful/openapi/gateful.json @@ -2778,6 +2778,133 @@ } }, "required": ["hasEnoughBalance", "balanceWei", "thresholdWei"] + }, + "DraftProposalList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DraftProposal" + } + } + }, + "required": ["items"] + }, + "DraftProposal": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "daoId": { + "type": "string" + }, + "author": { + "type": "string", + "format": "ethereum-address" + }, + "title": { + "type": "string" + }, + "discussionUrl": { + "type": "string" + }, + "body": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DraftAction" + } + }, + "createdAt": { + "type": "string", + "format": "bigint", + "description": "Creation timestamp in Unix milliseconds, as a decimal string." + }, + "updatedAt": { + "type": "string", + "format": "bigint", + "description": "Last-updated timestamp in Unix milliseconds, as a decimal string." + } + }, + "required": [ + "id", + "daoId", + "author", + "title", + "discussionUrl", + "body", + "actions", + "createdAt", + "updatedAt" + ] + }, + "DraftAction": { + "type": "object", + "additionalProperties": { + "nullable": true + }, + "description": "A single proposal action encoded as a JSON object." + }, + "CreateDraftBody": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Client-generated UUID for the draft." + }, + "address": { + "type": "string" + }, + "title": { + "type": "string", + "default": "" + }, + "discussionUrl": { + "type": "string", + "default": "" + }, + "body": { + "type": "string", + "default": "" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DraftAction" + }, + "default": [] + } + }, + "required": ["id", "address"] + }, + "UpdateDraftBody": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "title": { + "type": "string" + }, + "discussionUrl": { + "type": "string" + }, + "body": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DraftAction" + } + } + }, + "required": ["address"] } }, "parameters": {}, @@ -2847,7 +2974,7 @@ ], "responses": { "200": { - "description": "Per-DAO health snapshot \u2014 database, chain head, indexer freshness, and gateway circuit-breaker state.", + "description": "Per-DAO health snapshot — database, chain head, indexer freshness, and gateway circuit-breaker state.", "content": { "application/json": { "schema": { @@ -3399,7 +3526,7 @@ "get": { "operationId": "getDaoTokenTreasury", "summary": "Get DAO token treasury data", - "description": "Get historical DAO Token Treasury value (governance token quantity \u00d7 token price)", + "description": "Get historical DAO Token Treasury value (governance token quantity × token price)", "tags": ["treasury"], "parameters": [ { @@ -8745,6 +8872,217 @@ } } } + }, + "/{dao}/proposal-drafts": { + "get": { + "operationId": "getDraftProposals", + "summary": "List draft proposals for an address", + "tags": ["draft-proposals"], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "address", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Draft proposals owned by the given address", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DraftProposalList" + } + } + } + } + } + }, + "post": { + "operationId": "createDraftProposal", + "summary": "Create a draft proposal", + "tags": ["draft-proposals"], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDraftBody" + } + } + } + }, + "responses": { + "201": { + "description": "The created draft proposal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DraftProposal" + } + } + } + } + } + }, + "parameters": [ + { + "name": "dao", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": ["ens"] + }, + "description": "DAO identifier" + } + ] + }, + "/{dao}/proposal-drafts/{id}": { + "get": { + "operationId": "getDraftProposal", + "summary": "Get a draft proposal by ID", + "description": "Public endpoint — anyone with the ID can view the draft, enabling sharing.", + "tags": ["draft-proposals"], + "parameters": [ + { + "schema": { + "type": "string", + "description": "UUID of the draft." + }, + "required": true, + "description": "UUID of the draft.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The requested draft proposal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DraftProposal" + } + } + } + }, + "404": { + "description": "Draft not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "put": { + "operationId": "updateDraftProposal", + "summary": "Update a draft proposal", + "description": "Only the original author (matched by address) can update a draft.", + "tags": ["draft-proposals"], + "parameters": [ + { + "schema": { + "type": "string", + "description": "UUID of the draft." + }, + "required": true, + "description": "UUID of the draft.", + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDraftBody" + } + } + } + }, + "responses": { + "200": { + "description": "The updated draft proposal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DraftProposal" + } + } + } + }, + "404": { + "description": "Draft not found or address does not match author", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "delete": { + "operationId": "deleteDraftProposal", + "summary": "Delete a draft proposal", + "description": "Only the original author (matched by address) can delete a draft.", + "tags": ["draft-proposals"], + "parameters": [ + { + "schema": { + "type": "string", + "description": "UUID of the draft." + }, + "required": true, + "description": "UUID of the draft.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "address", + "in": "query" + } + ], + "responses": { + "204": { + "description": "Draft deleted" + }, + "404": { + "description": "Draft not found or address does not match author", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "parameters": [ + { + "name": "dao", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": ["ens"] + }, + "description": "DAO identifier" + } + ] } }, "webhooks": {}, diff --git a/packages/graphql-client/codegen.ts b/packages/graphql-client/codegen.ts index 36ffa0778..4e082113d 100644 --- a/packages/graphql-client/codegen.ts +++ b/packages/graphql-client/codegen.ts @@ -24,6 +24,8 @@ const config: CodegenConfig = { PositiveInt: "number", ObjMap: "Record", NonEmptyString: "string", + UUID: "string", + Void: "void", }, }, }, diff --git a/scripts/dev.sh b/scripts/dev.sh index 7195598aa..d392c0716 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -23,7 +23,8 @@ PORT_GATEWAY=4000 PORT_GATEFUL=4001 PORT_DASHBOARD=3000 PORT_ADDRESS_ENRICHMENT=3001 -PORTS=("$PORT_INDEXER" "$PORT_API" "$PORT_GATEWAY" "$PORT_GATEFUL" "$PORT_DASHBOARD" "$PORT_ADDRESS_ENRICHMENT") +PORT_RELAYER=3002 +PORTS=("$PORT_INDEXER" "$PORT_API" "$PORT_GATEWAY" "$PORT_GATEFUL" "$PORT_DASHBOARD" "$PORT_ADDRESS_ENRICHMENT" "$PORT_RELAYER") # DAO name → short ID mapping (used to run the API) dao_id_for() { @@ -53,6 +54,7 @@ C_GATEFUL="\033[36m" # cyan C_CODEGEN="\033[33m" # yellow C_DASHBOARD="\033[32m" # green C_ADDRESS_ENRICHMENT="\033[96m" # bright cyan +C_RELAYER="\033[93m" # bright yellow C_SCRIPT="\033[90m" # gray C_RESET="\033[0m" @@ -131,6 +133,12 @@ start_gateful() { wait_for_port "$PORT_GATEFUL" "Gateful" 120 } +start_relayer() { + log "Starting Relayer..." + run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway_run relayer pnpm relayer dev & + wait_for_port "$PORT_RELAYER" "Relayer" 60 +} + if [ "${BASH_SOURCE[0]}" != "$0" ]; then return 0 fi @@ -267,14 +275,17 @@ fi # 5. Gateful start_gateful -# 6. Clients — codegen + build watch +# 6. Relayer +start_relayer + +# 7. Clients — codegen + build watch export ANTICAPTURE_GRAPHQL_ENDPOINT="http://localhost:${PORT_GATEWAY}/graphql" log "Starting GraphQL Client (silent, errors only)..." run_errors_only "$C_CODEGEN" "🤝 gql-client" pnpm gql-client dev & log "Starting REST Client (silent, errors only)..." run_errors_only "$C_CODEGEN" "🤝 client" pnpm client dev & -# 7. Dashboard +# 8. Dashboard export NEXT_PUBLIC_BASE_URL="http://localhost:${PORT_GATEWAY}/graphql" export NEXT_PUBLIC_GATEFUL_URL="http://localhost:${PORT_GATEFUL}" log "Starting Dashboard..." @@ -295,6 +306,7 @@ else fi printf " ${C_GATEWAY}🌎 Gateway${C_RESET} http://localhost:${PORT_GATEWAY}\n" printf " ${C_GATEFUL}🚪 Gateful${C_RESET} http://localhost:${PORT_GATEFUL}\n" +printf " ${C_RELAYER}📡 Relayer${C_RESET} http://localhost:${PORT_RELAYER}\n" printf " ${C_CODEGEN}🤝 GraphQL Client${C_RESET} codegen + build watch\n" printf " ${C_CODEGEN}🤝 REST Client${C_RESET} codegen + build watch\n" printf " ${C_DASHBOARD}📺 Dashboard${C_RESET} http://localhost:${PORT_DASHBOARD}\n"