Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/public-node-dao-proposals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"ui": patch
"api": patch
---

Anyone can read a community's Node DAO proposals at `/n/<slug>/proposals`, with each proposal's status, proposer, kind, votes and description at `/n/<slug>/proposals/<id>`. The community page links there when the community has a Node DAO. The DAO comes from the new public `getNodeDao` procedure, which reads the community's tenant owner live and returns nothing for communities owned by a person or the platform. Reading the DAO, the proposal list, an older page or a single proposal each fail with a retry instead of an empty list. Testnet DAOs aren't shown yet.
16 changes: 16 additions & 0 deletions api/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ export const MemberCountSchema = z.object({
observedAt: z.iso.datetime().nullable(),
});

export const NodeDaoSchema = z.object({
daoAccountId: z.string().nullable(),
network: z.enum(["mainnet", "testnet"]).nullable(),
});

export const AttendanceRecordSchema = z.object({
events: z.array(
z.object({
Expand Down Expand Up @@ -1029,6 +1034,17 @@ export const contract = oc.router({
.input(z.object({ nodeId: z.string() }))
.output(MemberCountSchema),

getNodeDao: oc
.route({
method: "GET",
path: "/nodes/{nodeId}/dao",
summary: "Get the Node DAO that owns a community",
description:
"Public — the Sputnik DAO recorded as the community's tenant owner, read live, with its network. Null when a person or the platform owns the community.",
})
.input(z.object({ nodeId: z.string() }))
.output(NodeDaoSchema),

getMyAttendance: oc
.route({
method: "GET",
Expand Down
11 changes: 11 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type MemberDirectoryService,
MemberDirectoryTag,
} from "./services/member-directory";
import { resolveNetwork } from "./services/near-rpc";
import type { NodeEffect, NodeRecord, NodesService } from "./services/nodes";
import { NodesLive, NodesTag } from "./services/nodes";
import {
Expand Down Expand Up @@ -966,6 +967,16 @@ export default createPlugin.withPlugins<PluginsClient>()({
return (yield* services.memberDirectory.latestCount(tenant.orgId)) ?? unavailable;
}),

getNodeDao: builder.getNodeDao.effect(function* ({ input }) {
const services = yield* ApiServices;
const none = { daoAccountId: null, network: null };
const node = yield* services.nodes.getById(input.nodeId);
if (!node?.tenantId) return none;
const tenant = yield* services.tenants.resolveTenantById(node.tenantId);
if (!tenant || tenant.ownerKind !== "dao") return none;
return { daoAccountId: tenant.accountId, network: resolveNetwork(tenant.accountId) };
}),

reconcileAttendance: builder.reconcileAttendance.use(requireAuth).effect(function* ({
input,
context,
Expand Down
73 changes: 73 additions & 0 deletions api/tests/integration/node-dao.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Effect } from "effect";
import { afterAll, describe, expect, it, vi } from "vitest";
import { daoContext, getPluginClient, teardown } from "../setup";

vi.mock("@/services/dao", () => ({
verifyDaoMembership: vi.fn(() =>
Effect.succeed({ isSputnikContract: true, isMember: true, policy: { roles: [] } }),
),
parsePolicyGroupMembers: vi.fn(() => []),
isExplicitDaoMember: vi.fn(() => true),
}));

afterAll(async () => {
await teardown();
});

async function daoCommunity(daoAccountId: string) {
const id = crypto.randomUUID().slice(0, 8);
const owner = await getPluginClient(daoContext(`dao-owner-${id}`, `dao-org-${id}`, daoAccountId));
const tenant = await owner.createTenant({ name: "DAO city", accountId: daoAccountId });
const node = await owner.createNode({
name: "DAO city",
slug: `dao-city-${id}`,
kind: "city",
tenantId: tenant.id,
});
return { owner, tenantId: tenant.id, nodeId: node.id };
}

describe("public Node DAO", () => {
it("returns the DAO that owns the community, signed out", async () => {
const dao = `nd-${crypto.randomUUID().slice(0, 8)}.sputnik-dao.near`;
const { nodeId } = await daoCommunity(dao);

const visitor = await getPluginClient();
expect(await visitor.getNodeDao({ nodeId })).toEqual({
daoAccountId: dao,
network: "mainnet",
});
});

it("follows a changed owner account on the next read, keeping nothing from before", async () => {
const before = `nd-${crypto.randomUUID().slice(0, 8)}.sputnik-dao.near`;
const after = `nd-${crypto.randomUUID().slice(0, 8)}.sputnik-dao.near`;
const { owner, tenantId, nodeId } = await daoCommunity(before);
const visitor = await getPluginClient();
expect((await visitor.getNodeDao({ nodeId })).daoAccountId).toBe(before);

await owner.updateTenant({ tenantId, accountId: after });

expect(await visitor.getNodeDao({ nodeId })).toEqual({
daoAccountId: after,
network: "mainnet",
});
});

it("reads the network from a testnet account", async () => {
const dao = `nd-${crypto.randomUUID().slice(0, 8)}.sputnik-dao.testnet`;
const { nodeId } = await daoCommunity(dao);

expect(await (await getPluginClient()).getNodeDao({ nodeId })).toEqual({
daoAccountId: dao,
network: "testnet",
});
});

it("has no DAO for an unknown community", async () => {
expect(await (await getPluginClient()).getNodeDao({ nodeId: crypto.randomUUID() })).toEqual({
daoAccountId: null,
network: null,
});
});
});
133 changes: 133 additions & 0 deletions ui/src/i18n/messages/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1008,4 +1008,137 @@ export const publicMessages = createMessageCatalogs(["en", "es", "fr", "zh"], {
"Le pool détient {total} de {count} stakers · frais de {fee}.",
"质押池共有 {count} 位质押者的 {total} · 费率 {fee}。",
],
"daoProposals.title": ["Proposals", "Propuestas", "Propositions", "提案"],
"daoProposals.description": [
"Proposals to {dao}, this community's Node DAO. Anyone can read them.",
"Propuestas a {dao}, la Node DAO de esta comunidad. Cualquiera puede leerlas.",
"Propositions à {dao}, la Node DAO de cette communauté. Tout le monde peut les lire.",
"提交给本社区 Node DAO {dao} 的提案,任何人都可以查看。",
],
"daoProposals.back": [
"Back to {community}",
"Volver a {community}",
"Retour à {community}",
"返回 {community}",
],
"daoProposals.noDao": [
"This community doesn't have a Node DAO yet.",
"Esta comunidad aún no tiene una Node DAO.",
"Cette communauté n'a pas encore de Node DAO.",
"该社区还没有 Node DAO。",
],
"daoProposals.testnet": [
"Proposals from testnet DAOs aren't shown here yet.",
"Las propuestas de DAO en testnet aún no se muestran aquí.",
"Les propositions des DAO testnet ne sont pas encore affichées ici.",
"这里暂不显示测试网 DAO 的提案。",
],
"daoProposals.daoError": [
"Couldn't look up this community's Node DAO.",
"No se pudo consultar la Node DAO de esta comunidad.",
"Impossible de trouver la Node DAO de cette communauté.",
"无法查询该社区的 Node DAO。",
],
"daoProposals.loadError": [
"Couldn't load proposals from {dao}.",
"No se pudieron cargar las propuestas de {dao}.",
"Impossible de charger les propositions de {dao}.",
"无法加载 {dao} 的提案。",
],
"daoProposals.retry": ["Try again", "Reintentar", "Réessayer", "重试"],
"daoProposals.empty": [
"No proposals yet.",
"Aún no hay propuestas.",
"Aucune proposition pour l'instant.",
"暂无提案。",
],
"daoProposals.showOlder": [
"Show older",
"Ver anteriores",
"Voir les plus anciennes",
"查看更早的提案",
],
"daoProposals.proposedBy": [
"Proposed by {account}",
"Propuesta de {account}",
"Proposée par {account}",
"提案人 {account}",
],
"daoProposals.submitted": ["Submitted", "Enviada", "Soumise", "提交时间"],
"daoProposals.proposer": ["Proposer", "Proponente", "Auteur", "提案人"],
"daoProposals.status": ["Status", "Estado", "Statut", "状态"],
"daoProposals.votes": ["Votes", "Votos", "Votes", "投票"],
"daoProposals.voteTally": [
"{approve} approve · {reject} reject",
"{approve} a favor · {reject} en contra",
"{approve} pour · {reject} contre",
"{approve} 赞成 · {reject} 反对",
],
"daoProposals.number": [
"Proposal #{id}",
"Propuesta n.º {id}",
"Proposition n° {id}",
"提案 #{id}",
],
"daoProposals.notFound": [
"Proposal not found",
"Propuesta no encontrada",
"Proposition introuvable",
"未找到提案",
],
"daoProposals.notFoundDescription": [
"Proposal #{id} doesn't exist in {dao}, or it was removed.",
"La propuesta n.º {id} no existe en {dao} o fue eliminada.",
"La proposition n° {id} n'existe pas dans {dao}, ou elle a été supprimée.",
"{dao} 中不存在提案 #{id},或它已被移除。",
],
"daoProposals.detailError": [
"Couldn't load proposal #{id} from {dao}.",
"No se pudo cargar la propuesta n.º {id} de {dao}.",
"Impossible de charger la proposition n° {id} de {dao}.",
"无法从 {dao} 加载提案 #{id}。",
],
"daoProposals.all": [
"All proposals",
"Todas las propuestas",
"Toutes les propositions",
"全部提案",
],
"daoProposals.openInTrezu": [
"Open the DAO in Trezu",
"Abrir la DAO en Trezu",
"Ouvrir la DAO dans Trezu",
"在 Trezu 中打开该 DAO",
],
"daoProposals.noDescription": [
"No description",
"Sin descripción",
"Aucune description",
"无描述",
],
"daoProposals.kindCall": [
"Calls {method} on {receiver}",
"Llama a {method} en {receiver}",
"Appelle {method} sur {receiver}",
"调用 {receiver} 上的 {method}",
],
"daoProposals.kindTransfer": [
"Transfer to {receiver}",
"Transferencia a {receiver}",
"Transfert vers {receiver}",
"转账给 {receiver}",
],
"daoProposals.kindOther": [
"{kind} proposal",
"Propuesta {kind}",
"Proposition {kind}",
"{kind} 提案",
],
"daoProposals.statusInProgress": ["Open", "Abierta", "Ouverte", "进行中"],
"daoProposals.statusApproved": ["Approved", "Aprobada", "Approuvée", "已通过"],
"daoProposals.statusRejected": ["Rejected", "Rechazada", "Rejetée", "已拒绝"],
"daoProposals.statusRemoved": ["Removed", "Eliminada", "Supprimée", "已移除"],
"daoProposals.statusExpired": ["Expired", "Vencida", "Expirée", "已过期"],
"daoProposals.statusMoved": ["Moved", "Movida", "Déplacée", "已转移"],
"daoProposals.statusFailed": ["Failed", "Fallida", "Échouée", "执行失败"],
});
40 changes: 40 additions & 0 deletions ui/src/lib/queries/node-dao.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
import type { ApiClient } from "@/app";
import { readDaoProposal, readDaoProposalPage } from "@/lib/sputnik-proposals";

export const nodeDaoQueryKeys = {
dao: (nodeId: string) => ["node-dao", nodeId] as const,
proposals: (daoAccountId: string) => ["node-dao", "proposals", daoAccountId] as const,
proposal: (daoAccountId: string, id: number) =>
["node-dao", "proposal", daoAccountId, id] as const,
};

/** The DAO recorded as the community's owner, read live from City Node (public). */
export function nodeDaoQueryOptions(apiClient: ApiClient, nodeId: string) {
return queryOptions({
queryKey: nodeDaoQueryKeys.dao(nodeId),
queryFn: () => apiClient.getNodeDao({ nodeId }),
staleTime: 60_000,
retry: false,
});
}

export function daoProposalPagesQueryOptions(daoAccountId: string) {
return infiniteQueryOptions({
queryKey: nodeDaoQueryKeys.proposals(daoAccountId),
queryFn: ({ pageParam }) => readDaoProposalPage(daoAccountId, { before: pageParam }),
initialPageParam: undefined as number | undefined,
getNextPageParam: (page) => page.nextBefore ?? undefined,
staleTime: 30_000,
retry: false,
});
}

export function daoProposalQueryOptions(daoAccountId: string, id: number) {
return queryOptions({
queryKey: nodeDaoQueryKeys.proposal(daoAccountId, id),
queryFn: () => readDaoProposal(daoAccountId, id),
staleTime: 30_000,
retry: false,
});
}
Loading
Loading