Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 215 lines 6.2 kB view raw
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 };