Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol.
app.exosphere.site
1import { Hono } from "hono";
2import { z } from "zod";
3import { getDb } from "@exosphere/core/db";
4import { eq, and, sql } from "@exosphere/core/db/drizzle";
5import { requireAuth, type AuthEnv } from "@exosphere/core/auth";
6import { getActiveMemberRole } from "@exosphere/core/sphere";
7import { requirePermission, checkPermission } from "@exosphere/core/permissions";
8import { putPdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds";
9import { resolveDidHandles } from "@exosphere/core/identity";
10import type { SphereEnv } from "@exosphere/core/types";
11import { featureRequests, featureRequestStatuses } from "../db/schema.ts";
12import { updateStatusSchema, markAsDuplicateSchema } from "../schemas/feature-request.ts";
13import { insertStatusAndUpdateFR } from "../db/operations.ts";
14
15const STATUS_COLLECTION = "site.exosphere.featureRequest.status";
16
17const app = new Hono<AuthEnv & SphereEnv>();
18
19// ---- Duplicates ----
20
21// Mark a feature request as duplicate of another
22app.post("/:id/duplicate", requireAuth, async (c) => {
23 const id = c.req.param("id");
24 const body = await c.req.json();
25 const result = markAsDuplicateSchema.safeParse(body);
26 if (!result.success) {
27 return c.json({ error: z.flattenError(result.error) }, 400);
28 }
29
30 const { duplicateOfId } = result.data;
31 const db = getDb();
32 const did = c.var.did;
33 const sphereId = c.var.sphereId;
34 const sphereVisibility = c.var.sphereVisibility;
35
36 // Check FR exists, belongs to this sphere, is "requested", and not hidden
37 const fr = db
38 .select({
39 id: featureRequests.id,
40 authorDid: featureRequests.authorDid,
41 status: featureRequests.status,
42 pdsUri: featureRequests.pdsUri,
43 })
44 .from(featureRequests)
45 .where(
46 and(
47 eq(featureRequests.id, id),
48 eq(featureRequests.sphereId, sphereId),
49 sql`${featureRequests.hiddenAt} is null`,
50 ),
51 )
52 .get();
53
54 if (!fr) {
55 return c.json({ error: "Feature request not found" }, 404);
56 }
57 if (fr.status !== "requested") {
58 return c.json({ error: "Only requested feature requests can be marked as duplicate" }, 400);
59 }
60
61 // Permission: admin/owner OR author
62 const role = getActiveMemberRole(sphereId, did);
63 const canMark =
64 checkPermission(sphereId, "feature-requests", "markDuplicate", role) || fr.authorDid === did;
65 if (!canMark) {
66 return c.json({ error: "Forbidden" }, 403);
67 }
68
69 // Check target exists and is not the same FR, not hidden
70 if (duplicateOfId === id) {
71 return c.json({ error: "Cannot mark as duplicate of itself" }, 400);
72 }
73
74 const target = db
75 .select({ id: featureRequests.id })
76 .from(featureRequests)
77 .where(
78 and(
79 eq(featureRequests.id, duplicateOfId),
80 eq(featureRequests.sphereId, sphereId),
81 sql`${featureRequests.hiddenAt} is null`,
82 ),
83 )
84 .get();
85 if (!target) {
86 return c.json({ error: "Target feature request not found" }, 404);
87 }
88
89 // Write to PDS for public spheres
90 let pdsUri: string | null = null;
91 if (sphereVisibility === "public" && fr.pdsUri) {
92 const session = c.var.session;
93 pdsUri = await putPdsRecord(session, STATUS_COLLECTION, id, {
94 subject: fr.pdsUri,
95 status: "duplicate",
96 });
97 }
98
99 const statusId = generateRkey();
100 insertStatusAndUpdateFR({
101 id: statusId,
102 requestId: id,
103 authorDid: did,
104 status: "duplicate",
105 pdsUri,
106 duplicateOfId,
107 });
108
109 return c.json({ ok: true });
110});
111
112// Get FRs that are duplicates of a given FR
113app.get("/:id/duplicates", (c) => {
114 const id = c.req.param("id");
115 const db = getDb();
116
117 const rows = db
118 .select({
119 id: featureRequests.id,
120 number: featureRequests.number,
121 title: featureRequests.title,
122 })
123 .from(featureRequests)
124 .where(and(eq(featureRequests.duplicateOfId, id), sql`${featureRequests.hiddenAt} is null`))
125 .all();
126
127 return c.json({ duplicates: rows });
128});
129
130// ---- Statuses ----
131
132// Admin/owner-only: set status on a feature request
133app.post(
134 "/:id/status",
135 requireAuth,
136 requirePermission("feature-requests", "changeStatus"),
137 async (c) => {
138 const id = c.req.param("id");
139 const body = await c.req.json();
140 const result = updateStatusSchema.safeParse(body);
141 if (!result.success) {
142 return c.json({ error: z.flattenError(result.error) }, 400);
143 }
144
145 const { status } = result.data;
146 const db = getDb();
147 const did = c.var.did;
148 const sphereId = c.var.sphereId;
149 const sphereVisibility = c.var.sphereVisibility;
150
151 const existing = db
152 .select({
153 id: featureRequests.id,
154 status: featureRequests.status,
155 pdsUri: featureRequests.pdsUri,
156 })
157 .from(featureRequests)
158 .where(and(eq(featureRequests.id, id), eq(featureRequests.sphereId, sphereId)))
159 .get();
160 if (!existing) {
161 return c.json({ error: "Feature request not found" }, 404);
162 }
163
164 let pdsUri: string | null = null;
165
166 // Write to PDS for public spheres
167 if (sphereVisibility === "public" && existing.pdsUri) {
168 const session = c.var.session;
169 pdsUri = await putPdsRecord(session, STATUS_COLLECTION, id, {
170 subject: existing.pdsUri,
171 status,
172 });
173 }
174
175 const statusId = generateRkey();
176 insertStatusAndUpdateFR({
177 id: statusId,
178 requestId: id,
179 authorDid: did,
180 status,
181 pdsUri,
182 clearDuplicateOfId: existing.status === "duplicate",
183 });
184
185 return c.json({ status });
186 },
187);
188
189// Get status change history for a feature request
190app.get("/:id/statuses", async (c) => {
191 const id = c.req.param("id");
192 const db = getDb();
193
194 const rows = db
195 .select({
196 id: featureRequestStatuses.id,
197 authorDid: featureRequestStatuses.authorDid,
198 status: featureRequestStatuses.status,
199 })
200 .from(featureRequestStatuses)
201 .where(eq(featureRequestStatuses.requestId, id))
202 .orderBy(sql`${featureRequestStatuses.id} desc`)
203 .all();
204
205 const handleMap = await resolveDidHandles(rows.map((r) => r.authorDid));
206 const statuses = rows.map((r) => ({
207 ...r,
208 createdAt: tidToDate(r.id),
209 authorHandle: handleMap.get(r.authorDid) ?? null,
210 }));
211
212 return c.json({ statuses });
213});
214
215export { app as statusesApi };