WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

feat(appview): admin panel board management endpoints (ATB-45) (#79)

* test(appview): add failing tests for POST /api/admin/boards (ATB-45)

* test(appview): add ForumAgent not authenticated test for POST /api/admin/boards (ATB-45)

* feat(appview): POST /api/admin/boards create endpoint (ATB-45)

* test(appview): add failing tests for PUT /api/admin/boards/:id (ATB-45)

* test(appview): add error body assertion to PUT boards malformed JSON test (ATB-45)

* feat(appview): PUT /api/admin/boards/:id update endpoint (ATB-45)

* test(appview): add failing tests for DELETE /api/admin/boards/:id (ATB-45)

* test(appview): improve DELETE /api/admin/boards/:id test coverage (ATB-45)

* feat(appview): DELETE /api/admin/boards/:id delete endpoint (ATB-45)

Pre-flight refuses with 409 if any posts reference the board (via posts.boardId).
Also fixes test error messages to use "Database connection lost" (matching isDatabaseError keywords) for consistent 503 classification.

* docs(bruno): add board management API collection (ATB-45)

* fix(appview): close postgres connection after each admin test to prevent pool exhaustion (ATB-45)

Each createTestContext() call opens a new postgres.js connection pool. With 93
tests in admin.test.ts, the old pools were never closed, exhausting PostgreSQL's
max_connections limit. Fix by calling $client.end() in cleanup() for Postgres.

* docs(plans): move ATB-44 and ATB-45 plan docs to complete/

* test(appview): add missing DB error 503 tests for board endpoints (ATB-45)

- POST /api/admin/boards: add "returns 503 when category lookup query fails"
- PUT /api/admin/boards/:id: add "returns 503 when board lookup query fails"
- PUT /api/admin/boards/:id: add "returns 503 when category CID lookup query fails"
(call-count pattern: first select passes, second throws)

authored by

Malpercio and committed by
GitHub
680debb7 f53be905

+3615 -3
+6 -1
apps/appview/src/lib/__tests__/test-context.ts
··· 183 183 await db.delete(backfillErrors).catch(() => {}); 184 184 await db.delete(backfillProgress).catch(() => {}); 185 185 await db.delete(forums).where(eq(forums.did, config.forumDid)); 186 - // No sql.end() needed — createDb owns the client lifecycle 186 + // Close the postgres.js connection pool to prevent connection exhaustion. 187 + // With many tests each calling createTestContext(), every call opens a new 188 + // pool. Without end(), the pool stays open and PostgreSQL hits max_connections. 189 + if (isPostgres) { 190 + await (db as any).$client?.end?.(); 191 + } 187 192 }, 188 193 } as TestContext; 189 194 }
+739 -1
apps/appview/src/routes/__tests__/admin.test.ts
··· 2 2 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 3 import { Hono } from "hono"; 4 4 import type { Variables } from "../../types.js"; 5 - import { memberships, roles, rolePermissions, users, forums, categories, boards } from "@atbb/db"; 5 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts } from "@atbb/db"; 6 + import { eq } from "drizzle-orm"; 6 7 7 8 // Mock middleware at module level 8 9 let mockUser: any; ··· 1428 1429 1429 1430 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 1430 1431 const res = await testApp.request(`/api/admin/categories/${categoryId}`, { 1432 + method: "DELETE", 1433 + }); 1434 + 1435 + expect(res.status).toBe(403); 1436 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 1437 + 1438 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 1439 + await next(); 1440 + }); 1441 + }); 1442 + }); 1443 + 1444 + describe.sequential("POST /api/admin/boards", () => { 1445 + let categoryUri: string; 1446 + 1447 + beforeEach(async () => { 1448 + await ctx.cleanDatabase(); 1449 + 1450 + mockUser = { did: "did:plc:test-admin" }; 1451 + mockPutRecord.mockClear(); 1452 + mockDeleteRecord.mockClear(); 1453 + mockPutRecord.mockResolvedValue({ 1454 + data: { 1455 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid123`, 1456 + cid: "bafyboard", 1457 + }, 1458 + }); 1459 + 1460 + // Insert a category the tests can reference 1461 + await ctx.db.insert(categories).values({ 1462 + did: ctx.config.forumDid, 1463 + rkey: "tid-test-cat", 1464 + cid: "bafycat", 1465 + name: "Test Category", 1466 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 1467 + indexedAt: new Date(), 1468 + }); 1469 + categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 1470 + }); 1471 + 1472 + it("creates board with valid body → 201 and putRecord called with categoryRef", async () => { 1473 + const res = await app.request("/api/admin/boards", { 1474 + method: "POST", 1475 + headers: { "Content-Type": "application/json" }, 1476 + body: JSON.stringify({ name: "General Chat", description: "Talk here.", sortOrder: 1, categoryUri }), 1477 + }); 1478 + 1479 + expect(res.status).toBe(201); 1480 + const data = await res.json(); 1481 + expect(data.uri).toContain("/space.atbb.forum.board/"); 1482 + expect(data.cid).toBe("bafyboard"); 1483 + expect(mockPutRecord).toHaveBeenCalledWith( 1484 + expect.objectContaining({ 1485 + repo: ctx.config.forumDid, 1486 + collection: "space.atbb.forum.board", 1487 + rkey: expect.any(String), 1488 + record: expect.objectContaining({ 1489 + $type: "space.atbb.forum.board", 1490 + name: "General Chat", 1491 + description: "Talk here.", 1492 + sortOrder: 1, 1493 + category: { category: { uri: categoryUri, cid: "bafycat" } }, 1494 + createdAt: expect.any(String), 1495 + }), 1496 + }) 1497 + ); 1498 + }); 1499 + 1500 + it("creates board without optional fields → 201", async () => { 1501 + const res = await app.request("/api/admin/boards", { 1502 + method: "POST", 1503 + headers: { "Content-Type": "application/json" }, 1504 + body: JSON.stringify({ name: "Minimal", categoryUri }), 1505 + }); 1506 + 1507 + expect(res.status).toBe(201); 1508 + expect(mockPutRecord).toHaveBeenCalledWith( 1509 + expect.objectContaining({ 1510 + record: expect.objectContaining({ name: "Minimal" }), 1511 + }) 1512 + ); 1513 + }); 1514 + 1515 + it("returns 400 when name is missing → no PDS write", async () => { 1516 + const res = await app.request("/api/admin/boards", { 1517 + method: "POST", 1518 + headers: { "Content-Type": "application/json" }, 1519 + body: JSON.stringify({ categoryUri }), 1520 + }); 1521 + 1522 + expect(res.status).toBe(400); 1523 + const data = await res.json(); 1524 + expect(data.error).toContain("name"); 1525 + expect(mockPutRecord).not.toHaveBeenCalled(); 1526 + }); 1527 + 1528 + it("returns 400 when name is empty string → no PDS write", async () => { 1529 + const res = await app.request("/api/admin/boards", { 1530 + method: "POST", 1531 + headers: { "Content-Type": "application/json" }, 1532 + body: JSON.stringify({ name: " ", categoryUri }), 1533 + }); 1534 + 1535 + expect(res.status).toBe(400); 1536 + expect(mockPutRecord).not.toHaveBeenCalled(); 1537 + }); 1538 + 1539 + it("returns 400 when categoryUri is missing → no PDS write", async () => { 1540 + const res = await app.request("/api/admin/boards", { 1541 + method: "POST", 1542 + headers: { "Content-Type": "application/json" }, 1543 + body: JSON.stringify({ name: "Test Board" }), 1544 + }); 1545 + 1546 + expect(res.status).toBe(400); 1547 + const data = await res.json(); 1548 + expect(data.error).toContain("categoryUri"); 1549 + expect(mockPutRecord).not.toHaveBeenCalled(); 1550 + }); 1551 + 1552 + it("returns 404 when categoryUri references unknown category → no PDS write", async () => { 1553 + const res = await app.request("/api/admin/boards", { 1554 + method: "POST", 1555 + headers: { "Content-Type": "application/json" }, 1556 + body: JSON.stringify({ name: "Test Board", categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/unknown999` }), 1557 + }); 1558 + 1559 + expect(res.status).toBe(404); 1560 + const data = await res.json(); 1561 + expect(data.error).toContain("Category not found"); 1562 + expect(mockPutRecord).not.toHaveBeenCalled(); 1563 + }); 1564 + 1565 + it("returns 400 for malformed JSON", async () => { 1566 + const res = await app.request("/api/admin/boards", { 1567 + method: "POST", 1568 + headers: { "Content-Type": "application/json" }, 1569 + body: "{ bad json }", 1570 + }); 1571 + 1572 + expect(res.status).toBe(400); 1573 + const data = await res.json(); 1574 + expect(data.error).toContain("Invalid JSON"); 1575 + expect(mockPutRecord).not.toHaveBeenCalled(); 1576 + }); 1577 + 1578 + it("returns 401 when unauthenticated → no PDS write", async () => { 1579 + mockUser = null; 1580 + 1581 + const res = await app.request("/api/admin/boards", { 1582 + method: "POST", 1583 + headers: { "Content-Type": "application/json" }, 1584 + body: JSON.stringify({ name: "Test", categoryUri }), 1585 + }); 1586 + 1587 + expect(res.status).toBe(401); 1588 + expect(mockPutRecord).not.toHaveBeenCalled(); 1589 + }); 1590 + 1591 + it("returns 503 when PDS network error", async () => { 1592 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 1593 + 1594 + const res = await app.request("/api/admin/boards", { 1595 + method: "POST", 1596 + headers: { "Content-Type": "application/json" }, 1597 + body: JSON.stringify({ name: "Test", categoryUri }), 1598 + }); 1599 + 1600 + expect(res.status).toBe(503); 1601 + const data = await res.json(); 1602 + expect(data.error).toContain("Unable to reach external service"); 1603 + expect(mockPutRecord).toHaveBeenCalled(); 1604 + }); 1605 + 1606 + it("returns 500 when ForumAgent unavailable", async () => { 1607 + ctx.forumAgent = null; 1608 + 1609 + const res = await app.request("/api/admin/boards", { 1610 + method: "POST", 1611 + headers: { "Content-Type": "application/json" }, 1612 + body: JSON.stringify({ name: "Test", categoryUri }), 1613 + }); 1614 + 1615 + expect(res.status).toBe(500); 1616 + const data = await res.json(); 1617 + expect(data.error).toContain("Forum agent not available"); 1618 + }); 1619 + 1620 + it("returns 503 when ForumAgent not authenticated", async () => { 1621 + const originalAgent = ctx.forumAgent; 1622 + ctx.forumAgent = { getAgent: () => null } as any; 1623 + 1624 + const res = await app.request("/api/admin/boards", { 1625 + method: "POST", 1626 + headers: { "Content-Type": "application/json" }, 1627 + body: JSON.stringify({ name: "Test", categoryUri }), 1628 + }); 1629 + 1630 + expect(res.status).toBe(503); 1631 + const data = await res.json(); 1632 + expect(data.error).toBe("Forum agent not authenticated. Please try again later."); 1633 + expect(mockPutRecord).not.toHaveBeenCalled(); 1634 + 1635 + ctx.forumAgent = originalAgent; 1636 + }); 1637 + 1638 + it("returns 503 when category lookup query fails", async () => { 1639 + const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 1640 + throw new Error("Database connection lost"); 1641 + }); 1642 + 1643 + const res = await app.request("/api/admin/boards", { 1644 + method: "POST", 1645 + headers: { "Content-Type": "application/json" }, 1646 + body: JSON.stringify({ name: "Test Board", categoryUri }), 1647 + }); 1648 + 1649 + expect(res.status).toBe(503); 1650 + const data = await res.json(); 1651 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1652 + expect(mockPutRecord).not.toHaveBeenCalled(); 1653 + 1654 + dbSelectSpy.mockRestore(); 1655 + }); 1656 + 1657 + it("returns 403 when user lacks manageCategories permission", async () => { 1658 + const { requirePermission } = await import("../../middleware/permissions.js"); 1659 + const mockRequirePermission = requirePermission as any; 1660 + mockRequirePermission.mockImplementation(() => async (c: any) => { 1661 + return c.json({ error: "Forbidden" }, 403); 1662 + }); 1663 + 1664 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 1665 + const res = await testApp.request("/api/admin/boards", { 1666 + method: "POST", 1667 + headers: { "Content-Type": "application/json" }, 1668 + body: JSON.stringify({ name: "Test", categoryUri }), 1669 + }); 1670 + 1671 + expect(res.status).toBe(403); 1672 + expect(mockPutRecord).not.toHaveBeenCalled(); 1673 + 1674 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 1675 + await next(); 1676 + }); 1677 + }); 1678 + }); 1679 + 1680 + describe.sequential("PUT /api/admin/boards/:id", () => { 1681 + let boardId: string; 1682 + let categoryUri: string; 1683 + 1684 + beforeEach(async () => { 1685 + await ctx.cleanDatabase(); 1686 + 1687 + mockUser = { did: "did:plc:test-admin" }; 1688 + mockPutRecord.mockClear(); 1689 + mockDeleteRecord.mockClear(); 1690 + mockPutRecord.mockResolvedValue({ 1691 + data: { 1692 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`, 1693 + cid: "bafyboardupdated", 1694 + }, 1695 + }); 1696 + 1697 + // Insert a category and a board 1698 + const [cat] = await ctx.db.insert(categories).values({ 1699 + did: ctx.config.forumDid, 1700 + rkey: "tid-test-cat", 1701 + cid: "bafycat", 1702 + name: "Test Category", 1703 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 1704 + indexedAt: new Date(), 1705 + }).returning({ id: categories.id }); 1706 + 1707 + categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 1708 + 1709 + const [brd] = await ctx.db.insert(boards).values({ 1710 + did: ctx.config.forumDid, 1711 + rkey: "tid-test-board", 1712 + cid: "bafyboard", 1713 + name: "Original Name", 1714 + description: "Original description", 1715 + sortOrder: 1, 1716 + categoryId: cat.id, 1717 + categoryUri, 1718 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 1719 + indexedAt: new Date(), 1720 + }).returning({ id: boards.id }); 1721 + 1722 + boardId = brd.id.toString(); 1723 + }); 1724 + 1725 + it("updates board with all fields → 200 and putRecord called with same rkey", async () => { 1726 + const res = await app.request(`/api/admin/boards/${boardId}`, { 1727 + method: "PUT", 1728 + headers: { "Content-Type": "application/json" }, 1729 + body: JSON.stringify({ name: "Renamed Board", description: "New description", sortOrder: 2 }), 1730 + }); 1731 + 1732 + expect(res.status).toBe(200); 1733 + const data = await res.json(); 1734 + expect(data.uri).toContain("/space.atbb.forum.board/"); 1735 + expect(data.cid).toBe("bafyboardupdated"); 1736 + expect(mockPutRecord).toHaveBeenCalledWith( 1737 + expect.objectContaining({ 1738 + repo: ctx.config.forumDid, 1739 + collection: "space.atbb.forum.board", 1740 + rkey: "tid-test-board", 1741 + record: expect.objectContaining({ 1742 + $type: "space.atbb.forum.board", 1743 + name: "Renamed Board", 1744 + description: "New description", 1745 + sortOrder: 2, 1746 + category: { category: { uri: categoryUri, cid: "bafycat" } }, 1747 + }), 1748 + }) 1749 + ); 1750 + }); 1751 + 1752 + it("updates board without optional fields → falls back to existing values", async () => { 1753 + const res = await app.request(`/api/admin/boards/${boardId}`, { 1754 + method: "PUT", 1755 + headers: { "Content-Type": "application/json" }, 1756 + body: JSON.stringify({ name: "Renamed Only" }), 1757 + }); 1758 + 1759 + expect(res.status).toBe(200); 1760 + expect(mockPutRecord).toHaveBeenCalledWith( 1761 + expect.objectContaining({ 1762 + record: expect.objectContaining({ 1763 + name: "Renamed Only", 1764 + description: "Original description", 1765 + sortOrder: 1, 1766 + }), 1767 + }) 1768 + ); 1769 + }); 1770 + 1771 + it("returns 400 when name is missing", async () => { 1772 + const res = await app.request(`/api/admin/boards/${boardId}`, { 1773 + method: "PUT", 1774 + headers: { "Content-Type": "application/json" }, 1775 + body: JSON.stringify({ description: "No name" }), 1776 + }); 1777 + 1778 + expect(res.status).toBe(400); 1779 + const data = await res.json(); 1780 + expect(data.error).toContain("name"); 1781 + expect(mockPutRecord).not.toHaveBeenCalled(); 1782 + }); 1783 + 1784 + it("returns 400 when name is empty string", async () => { 1785 + const res = await app.request(`/api/admin/boards/${boardId}`, { 1786 + method: "PUT", 1787 + headers: { "Content-Type": "application/json" }, 1788 + body: JSON.stringify({ name: " " }), 1789 + }); 1790 + 1791 + expect(res.status).toBe(400); 1792 + expect(mockPutRecord).not.toHaveBeenCalled(); 1793 + }); 1794 + 1795 + it("returns 400 for non-numeric ID", async () => { 1796 + const res = await app.request("/api/admin/boards/not-a-number", { 1797 + method: "PUT", 1798 + headers: { "Content-Type": "application/json" }, 1799 + body: JSON.stringify({ name: "Test" }), 1800 + }); 1801 + 1802 + expect(res.status).toBe(400); 1803 + expect(mockPutRecord).not.toHaveBeenCalled(); 1804 + }); 1805 + 1806 + it("returns 404 when board not found", async () => { 1807 + const res = await app.request("/api/admin/boards/99999", { 1808 + method: "PUT", 1809 + headers: { "Content-Type": "application/json" }, 1810 + body: JSON.stringify({ name: "Test" }), 1811 + }); 1812 + 1813 + expect(res.status).toBe(404); 1814 + const data = await res.json(); 1815 + expect(data.error).toContain("Board not found"); 1816 + expect(mockPutRecord).not.toHaveBeenCalled(); 1817 + }); 1818 + 1819 + it("returns 400 for malformed JSON", async () => { 1820 + const res = await app.request(`/api/admin/boards/${boardId}`, { 1821 + method: "PUT", 1822 + headers: { "Content-Type": "application/json" }, 1823 + body: "{ bad json }", 1824 + }); 1825 + 1826 + expect(res.status).toBe(400); 1827 + const data = await res.json(); 1828 + expect(data.error).toContain("Invalid JSON"); 1829 + expect(mockPutRecord).not.toHaveBeenCalled(); 1830 + }); 1831 + 1832 + it("returns 401 when unauthenticated", async () => { 1833 + mockUser = null; 1834 + 1835 + const res = await app.request(`/api/admin/boards/${boardId}`, { 1836 + method: "PUT", 1837 + headers: { "Content-Type": "application/json" }, 1838 + body: JSON.stringify({ name: "Test" }), 1839 + }); 1840 + 1841 + expect(res.status).toBe(401); 1842 + expect(mockPutRecord).not.toHaveBeenCalled(); 1843 + }); 1844 + 1845 + it("returns 503 when PDS network error", async () => { 1846 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 1847 + 1848 + const res = await app.request(`/api/admin/boards/${boardId}`, { 1849 + method: "PUT", 1850 + headers: { "Content-Type": "application/json" }, 1851 + body: JSON.stringify({ name: "Test" }), 1852 + }); 1853 + 1854 + expect(res.status).toBe(503); 1855 + const data = await res.json(); 1856 + expect(data.error).toContain("Unable to reach external service"); 1857 + }); 1858 + 1859 + it("returns 500 when ForumAgent unavailable", async () => { 1860 + ctx.forumAgent = null; 1861 + 1862 + const res = await app.request(`/api/admin/boards/${boardId}`, { 1863 + method: "PUT", 1864 + headers: { "Content-Type": "application/json" }, 1865 + body: JSON.stringify({ name: "Test" }), 1866 + }); 1867 + 1868 + expect(res.status).toBe(500); 1869 + const data = await res.json(); 1870 + expect(data.error).toContain("Forum agent not available"); 1871 + }); 1872 + 1873 + it("returns 503 when ForumAgent not authenticated", async () => { 1874 + const originalAgent = ctx.forumAgent; 1875 + ctx.forumAgent = { getAgent: () => null } as any; 1876 + 1877 + const res = await app.request(`/api/admin/boards/${boardId}`, { 1878 + method: "PUT", 1879 + headers: { "Content-Type": "application/json" }, 1880 + body: JSON.stringify({ name: "Test" }), 1881 + }); 1882 + 1883 + expect(res.status).toBe(503); 1884 + const data = await res.json(); 1885 + expect(data.error).toBe("Forum agent not authenticated. Please try again later."); 1886 + expect(mockPutRecord).not.toHaveBeenCalled(); 1887 + 1888 + ctx.forumAgent = originalAgent; 1889 + }); 1890 + 1891 + it("returns 403 when user lacks manageCategories permission", async () => { 1892 + const { requirePermission } = await import("../../middleware/permissions.js"); 1893 + const mockRequirePermission = requirePermission as any; 1894 + mockRequirePermission.mockImplementation(() => async (c: any) => { 1895 + return c.json({ error: "Forbidden" }, 403); 1896 + }); 1897 + 1898 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 1899 + const res = await testApp.request(`/api/admin/boards/${boardId}`, { 1900 + method: "PUT", 1901 + headers: { "Content-Type": "application/json" }, 1902 + body: JSON.stringify({ name: "Test" }), 1903 + }); 1904 + 1905 + expect(res.status).toBe(403); 1906 + expect(mockPutRecord).not.toHaveBeenCalled(); 1907 + 1908 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 1909 + await next(); 1910 + }); 1911 + }); 1912 + 1913 + it("returns 503 when board lookup query fails", async () => { 1914 + const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 1915 + throw new Error("Database connection lost"); 1916 + }); 1917 + 1918 + const res = await app.request(`/api/admin/boards/${boardId}`, { 1919 + method: "PUT", 1920 + headers: { "Content-Type": "application/json" }, 1921 + body: JSON.stringify({ name: "Updated Name" }), 1922 + }); 1923 + 1924 + expect(res.status).toBe(503); 1925 + const data = await res.json(); 1926 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1927 + expect(mockPutRecord).not.toHaveBeenCalled(); 1928 + 1929 + dbSelectSpy.mockRestore(); 1930 + }); 1931 + 1932 + it("returns 503 when category CID lookup query fails", async () => { 1933 + const originalSelect = ctx.db.select.bind(ctx.db); 1934 + let callCount = 0; 1935 + const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementation((...args: any[]) => { 1936 + callCount++; 1937 + if (callCount === 1) { 1938 + // First call: board lookup — pass through to real DB 1939 + return (originalSelect as any)(...args); 1940 + } 1941 + // Second call: category CID fetch — throw DB error 1942 + throw new Error("Database connection lost"); 1943 + }); 1944 + 1945 + const res = await app.request(`/api/admin/boards/${boardId}`, { 1946 + method: "PUT", 1947 + headers: { "Content-Type": "application/json" }, 1948 + body: JSON.stringify({ name: "Updated Name" }), 1949 + }); 1950 + 1951 + expect(res.status).toBe(503); 1952 + const data = await res.json(); 1953 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1954 + expect(mockPutRecord).not.toHaveBeenCalled(); 1955 + 1956 + dbSelectSpy.mockRestore(); 1957 + }); 1958 + }); 1959 + 1960 + describe.sequential("DELETE /api/admin/boards/:id", () => { 1961 + let boardId: string; 1962 + let categoryUri: string; 1963 + 1964 + beforeEach(async () => { 1965 + await ctx.cleanDatabase(); 1966 + 1967 + mockUser = { did: "did:plc:test-admin" }; 1968 + mockPutRecord.mockClear(); 1969 + mockDeleteRecord.mockClear(); 1970 + mockDeleteRecord.mockResolvedValue({}); 1971 + 1972 + // Insert a category and a board 1973 + const [cat] = await ctx.db.insert(categories).values({ 1974 + did: ctx.config.forumDid, 1975 + rkey: "tid-test-cat", 1976 + cid: "bafycat", 1977 + name: "Test Category", 1978 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 1979 + indexedAt: new Date(), 1980 + }).returning({ id: categories.id }); 1981 + 1982 + categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 1983 + 1984 + const [brd] = await ctx.db.insert(boards).values({ 1985 + did: ctx.config.forumDid, 1986 + rkey: "tid-test-board", 1987 + cid: "bafyboard", 1988 + name: "Test Board", 1989 + categoryId: cat.id, 1990 + categoryUri, 1991 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 1992 + indexedAt: new Date(), 1993 + }).returning({ id: boards.id }); 1994 + 1995 + boardId = brd.id.toString(); 1996 + }); 1997 + 1998 + it("deletes empty board → 200 and deleteRecord called", async () => { 1999 + const res = await app.request(`/api/admin/boards/${boardId}`, { 2000 + method: "DELETE", 2001 + }); 2002 + 2003 + expect(res.status).toBe(200); 2004 + const data = await res.json(); 2005 + expect(data.success).toBe(true); 2006 + expect(mockDeleteRecord).toHaveBeenCalledWith({ 2007 + repo: ctx.config.forumDid, 2008 + collection: "space.atbb.forum.board", 2009 + rkey: "tid-test-board", 2010 + }); 2011 + }); 2012 + 2013 + it("returns 409 when board has posts → deleteRecord NOT called", async () => { 2014 + // Insert a user and a post referencing this board 2015 + await ctx.db.insert(users).values({ 2016 + did: "did:plc:test-user", 2017 + handle: "testuser.bsky.social", 2018 + indexedAt: new Date(), 2019 + }); 2020 + 2021 + const [brd] = await ctx.db.select().from(boards).where(eq(boards.rkey, "tid-test-board")).limit(1); 2022 + 2023 + await ctx.db.insert(posts).values({ 2024 + did: "did:plc:test-user", 2025 + rkey: "tid-test-post", 2026 + cid: "bafypost", 2027 + text: "Hello world", 2028 + boardId: brd.id, 2029 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`, 2030 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 2031 + createdAt: new Date(), 2032 + indexedAt: new Date(), 2033 + }); 2034 + 2035 + const res = await app.request(`/api/admin/boards/${boardId}`, { 2036 + method: "DELETE", 2037 + }); 2038 + 2039 + expect(res.status).toBe(409); 2040 + const data = await res.json(); 2041 + expect(data.error).toContain("posts"); 2042 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2043 + }); 2044 + 2045 + it("returns 400 for non-numeric ID", async () => { 2046 + const res = await app.request("/api/admin/boards/not-a-number", { 2047 + method: "DELETE", 2048 + }); 2049 + 2050 + expect(res.status).toBe(400); 2051 + const data = await res.json(); 2052 + expect(data.error).toContain("Invalid board ID"); 2053 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2054 + }); 2055 + 2056 + it("returns 404 when board not found", async () => { 2057 + const res = await app.request("/api/admin/boards/99999", { 2058 + method: "DELETE", 2059 + }); 2060 + 2061 + expect(res.status).toBe(404); 2062 + const data = await res.json(); 2063 + expect(data.error).toContain("Board not found"); 2064 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2065 + }); 2066 + 2067 + it("returns 503 when board lookup query fails", async () => { 2068 + const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 2069 + throw new Error("Database connection lost"); 2070 + }); 2071 + 2072 + const res = await app.request(`/api/admin/boards/${boardId}`, { 2073 + method: "DELETE", 2074 + }); 2075 + 2076 + expect(res.status).toBe(503); 2077 + const data = await res.json(); 2078 + expect(data.error).toContain("Please try again later"); 2079 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2080 + 2081 + dbSelectSpy.mockRestore(); 2082 + }); 2083 + 2084 + it("returns 503 when post count query fails", async () => { 2085 + const originalSelect = ctx.db.select.bind(ctx.db); 2086 + let callCount = 0; 2087 + const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementation((...args: any[]) => { 2088 + callCount++; 2089 + if (callCount === 1) { 2090 + // First call: board lookup — pass through to real DB 2091 + return (originalSelect as any)(...args); 2092 + } 2093 + // Second call: post count preflight — throw DB error 2094 + throw new Error("Database connection lost"); 2095 + }); 2096 + 2097 + const res = await app.request(`/api/admin/boards/${boardId}`, { 2098 + method: "DELETE", 2099 + }); 2100 + 2101 + expect(res.status).toBe(503); 2102 + const data = await res.json(); 2103 + expect(data.error).toContain("Please try again later"); 2104 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2105 + 2106 + dbSelectSpy.mockRestore(); 2107 + }); 2108 + 2109 + it("returns 401 when unauthenticated", async () => { 2110 + mockUser = null; 2111 + 2112 + const res = await app.request(`/api/admin/boards/${boardId}`, { 2113 + method: "DELETE", 2114 + }); 2115 + 2116 + expect(res.status).toBe(401); 2117 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2118 + }); 2119 + 2120 + it("returns 503 when PDS network error", async () => { 2121 + mockDeleteRecord.mockRejectedValue(new Error("fetch failed")); 2122 + 2123 + const res = await app.request(`/api/admin/boards/${boardId}`, { 2124 + method: "DELETE", 2125 + }); 2126 + 2127 + expect(res.status).toBe(503); 2128 + const data = await res.json(); 2129 + expect(data.error).toContain("Unable to reach external service"); 2130 + }); 2131 + 2132 + it("returns 500 when ForumAgent unavailable", async () => { 2133 + ctx.forumAgent = null; 2134 + 2135 + const res = await app.request(`/api/admin/boards/${boardId}`, { 2136 + method: "DELETE", 2137 + }); 2138 + 2139 + expect(res.status).toBe(500); 2140 + const data = await res.json(); 2141 + expect(data.error).toContain("Forum agent not available"); 2142 + }); 2143 + 2144 + it("returns 503 when ForumAgent not authenticated", async () => { 2145 + const originalAgent = ctx.forumAgent; 2146 + ctx.forumAgent = { getAgent: () => null } as any; 2147 + 2148 + const res = await app.request(`/api/admin/boards/${boardId}`, { 2149 + method: "DELETE", 2150 + }); 2151 + 2152 + expect(res.status).toBe(503); 2153 + const data = await res.json(); 2154 + expect(data.error).toBe("Forum agent not authenticated. Please try again later."); 2155 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2156 + 2157 + ctx.forumAgent = originalAgent; 2158 + }); 2159 + 2160 + it("returns 403 when user lacks manageCategories permission", async () => { 2161 + const { requirePermission } = await import("../../middleware/permissions.js"); 2162 + const mockRequirePermission = requirePermission as any; 2163 + mockRequirePermission.mockImplementation(() => async (c: any) => { 2164 + return c.json({ error: "Forbidden" }, 403); 2165 + }); 2166 + 2167 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 2168 + const res = await testApp.request(`/api/admin/boards/${boardId}`, { 1431 2169 method: "DELETE", 1432 2170 }); 1433 2171
+282 -1
apps/appview/src/routes/admin.ts
··· 3 3 import type { Variables } from "../types.js"; 4 4 import { requireAuth } from "../middleware/auth.js"; 5 5 import { requirePermission, getUserRole } from "../middleware/permissions.js"; 6 - import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards } from "@atbb/db"; 6 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts } from "@atbb/db"; 7 7 import { eq, and, sql, asc, count } from "drizzle-orm"; 8 8 import { isProgrammingError } from "../lib/errors.js"; 9 9 import { BackfillStatus } from "../lib/backfill-manager.js"; ··· 692 692 } catch (error) { 693 693 return handleRouteError(c, error, "Failed to delete category", { 694 694 operation: "DELETE /api/admin/categories/:id", 695 + logger: ctx.logger, 696 + id: idParam, 697 + }); 698 + } 699 + } 700 + ); 701 + 702 + /** 703 + * POST /api/admin/boards 704 + * 705 + * Create a new forum board within a category. Fetches the category's CID from DB 706 + * to build the categoryRef strongRef required by the lexicon. Writes 707 + * space.atbb.forum.board to the Forum DID's PDS via putRecord. 708 + * The firehose indexer creates the DB row asynchronously. 709 + */ 710 + app.post( 711 + "/boards", 712 + requireAuth(ctx), 713 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 714 + async (c) => { 715 + const { body, error: parseError } = await safeParseJsonBody(c); 716 + if (parseError) return parseError; 717 + 718 + const { name, description, sortOrder, categoryUri } = body; 719 + 720 + if (typeof name !== "string" || name.trim().length === 0) { 721 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 722 + } 723 + 724 + if (typeof categoryUri !== "string" || !categoryUri.startsWith("at://")) { 725 + return c.json({ error: "categoryUri is required and must be a valid AT URI" }, 400); 726 + } 727 + 728 + // Derive rkey from the categoryUri to look up the category in the DB 729 + const categoryRkey = categoryUri.split("/").pop(); 730 + 731 + let category: typeof categories.$inferSelect; 732 + try { 733 + const [row] = await ctx.db 734 + .select() 735 + .from(categories) 736 + .where( 737 + and( 738 + eq(categories.did, ctx.config.forumDid), 739 + eq(categories.rkey, categoryRkey ?? "") 740 + ) 741 + ) 742 + .limit(1); 743 + 744 + if (!row) { 745 + return c.json({ error: "Category not found" }, 404); 746 + } 747 + category = row; 748 + } catch (error) { 749 + return handleRouteError(c, error, "Failed to look up category", { 750 + operation: "POST /api/admin/boards", 751 + logger: ctx.logger, 752 + categoryUri, 753 + }); 754 + } 755 + 756 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/boards"); 757 + if (agentError) return agentError; 758 + 759 + const rkey = TID.nextStr(); 760 + const now = new Date().toISOString(); 761 + 762 + try { 763 + const result = await agent.com.atproto.repo.putRecord({ 764 + repo: ctx.config.forumDid, 765 + collection: "space.atbb.forum.board", 766 + rkey, 767 + record: { 768 + $type: "space.atbb.forum.board", 769 + name: name.trim(), 770 + ...(typeof description === "string" && { description: description.trim() }), 771 + ...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }), 772 + category: { category: { uri: categoryUri, cid: category.cid } }, 773 + createdAt: now, 774 + }, 775 + }); 776 + 777 + return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 778 + } catch (error) { 779 + return handleRouteError(c, error, "Failed to create board", { 780 + operation: "POST /api/admin/boards", 781 + logger: ctx.logger, 782 + categoryUri, 783 + }); 784 + } 785 + } 786 + ); 787 + 788 + /** 789 + * PUT /api/admin/boards/:id 790 + * 791 + * Update an existing board's name, description, and sortOrder. 792 + * Fetches existing rkey + categoryUri from DB, then fetches category CID, 793 + * then putRecord with updated fields preserving the original categoryRef and createdAt. 794 + * Category cannot be changed on edit (no reparenting). 795 + * The firehose indexer updates the DB row asynchronously. 796 + */ 797 + app.put( 798 + "/boards/:id", 799 + requireAuth(ctx), 800 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 801 + async (c) => { 802 + const idParam = c.req.param("id"); 803 + const id = parseBigIntParam(idParam); 804 + if (id === null) { 805 + return c.json({ error: "Invalid board ID" }, 400); 806 + } 807 + 808 + const { body, error: parseError } = await safeParseJsonBody(c); 809 + if (parseError) return parseError; 810 + 811 + const { name, description, sortOrder } = body; 812 + 813 + if (typeof name !== "string" || name.trim().length === 0) { 814 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 815 + } 816 + 817 + let board: typeof boards.$inferSelect; 818 + try { 819 + const [row] = await ctx.db 820 + .select() 821 + .from(boards) 822 + .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid))) 823 + .limit(1); 824 + 825 + if (!row) { 826 + return c.json({ error: "Board not found" }, 404); 827 + } 828 + board = row; 829 + } catch (error) { 830 + return handleRouteError(c, error, "Failed to look up board", { 831 + operation: "PUT /api/admin/boards/:id", 832 + logger: ctx.logger, 833 + id: idParam, 834 + }); 835 + } 836 + 837 + // Fetch category CID to rebuild the categoryRef strongRef. 838 + // Always fetch fresh — the category's CID can change after category edits. 839 + let categoryCid: string; 840 + try { 841 + const categoryRkey = board.categoryUri.split("/").pop() ?? ""; 842 + const [cat] = await ctx.db 843 + .select({ cid: categories.cid }) 844 + .from(categories) 845 + .where( 846 + and( 847 + eq(categories.did, ctx.config.forumDid), 848 + eq(categories.rkey, categoryRkey) 849 + ) 850 + ) 851 + .limit(1); 852 + 853 + if (!cat) { 854 + return c.json({ error: "Category not found" }, 404); 855 + } 856 + categoryCid = cat.cid; 857 + } catch (error) { 858 + return handleRouteError(c, error, "Failed to look up category", { 859 + operation: "PUT /api/admin/boards/:id", 860 + logger: ctx.logger, 861 + id: idParam, 862 + }); 863 + } 864 + 865 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/boards/:id"); 866 + if (agentError) return agentError; 867 + 868 + // putRecord is a full replacement — fall back to existing values for 869 + // optional fields not provided in the request body, to avoid data loss. 870 + const resolvedDescription = typeof description === "string" 871 + ? description.trim() 872 + : board.description; 873 + const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0) 874 + ? sortOrder 875 + : board.sortOrder; 876 + 877 + try { 878 + const result = await agent.com.atproto.repo.putRecord({ 879 + repo: ctx.config.forumDid, 880 + collection: "space.atbb.forum.board", 881 + rkey: board.rkey, 882 + record: { 883 + $type: "space.atbb.forum.board", 884 + name: name.trim(), 885 + ...(resolvedDescription != null && { description: resolvedDescription }), 886 + ...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }), 887 + category: { category: { uri: board.categoryUri, cid: categoryCid } }, 888 + createdAt: board.createdAt.toISOString(), 889 + }, 890 + }); 891 + 892 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 893 + } catch (error) { 894 + return handleRouteError(c, error, "Failed to update board", { 895 + operation: "PUT /api/admin/boards/:id", 896 + logger: ctx.logger, 897 + id: idParam, 898 + }); 899 + } 900 + } 901 + ); 902 + 903 + /** 904 + * DELETE /api/admin/boards/:id 905 + * 906 + * Delete a board. Pre-flight: refuses with 409 if any posts have boardId 907 + * pointing to this board. If clear, calls deleteRecord on the Forum DID's PDS. 908 + * The firehose indexer removes the DB row asynchronously. 909 + */ 910 + app.delete( 911 + "/boards/:id", 912 + requireAuth(ctx), 913 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 914 + async (c) => { 915 + const idParam = c.req.param("id"); 916 + const id = parseBigIntParam(idParam); 917 + if (id === null) { 918 + return c.json({ error: "Invalid board ID" }, 400); 919 + } 920 + 921 + let board: typeof boards.$inferSelect; 922 + try { 923 + const [row] = await ctx.db 924 + .select() 925 + .from(boards) 926 + .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid))) 927 + .limit(1); 928 + 929 + if (!row) { 930 + return c.json({ error: "Board not found" }, 404); 931 + } 932 + board = row; 933 + } catch (error) { 934 + return handleRouteError(c, error, "Failed to look up board", { 935 + operation: "DELETE /api/admin/boards/:id", 936 + logger: ctx.logger, 937 + id: idParam, 938 + }); 939 + } 940 + 941 + // Pre-flight: refuse if any posts reference this board 942 + try { 943 + const [postCount] = await ctx.db 944 + .select({ count: count() }) 945 + .from(posts) 946 + .where(eq(posts.boardId, id)); 947 + 948 + if (postCount && postCount.count > 0) { 949 + return c.json( 950 + { error: "Cannot delete board with posts. Remove all posts first." }, 951 + 409 952 + ); 953 + } 954 + } catch (error) { 955 + return handleRouteError(c, error, "Failed to check board posts", { 956 + operation: "DELETE /api/admin/boards/:id", 957 + logger: ctx.logger, 958 + id: idParam, 959 + }); 960 + } 961 + 962 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/boards/:id"); 963 + if (agentError) return agentError; 964 + 965 + try { 966 + await agent.com.atproto.repo.deleteRecord({ 967 + repo: ctx.config.forumDid, 968 + collection: "space.atbb.forum.board", 969 + rkey: board.rkey, 970 + }); 971 + 972 + return c.json({ success: true }); 973 + } catch (error) { 974 + return handleRouteError(c, error, "Failed to delete board", { 975 + operation: "DELETE /api/admin/boards/:id", 695 976 logger: ctx.logger, 696 977 id: idParam, 697 978 });
+52
bruno/AppView API/Admin/Create Board.bru
··· 1 + meta { 2 + name: Create Board 3 + type: http 4 + seq: 13 5 + } 6 + 7 + post { 8 + url: {{appview_url}}/api/admin/boards 9 + } 10 + 11 + body:json { 12 + { 13 + "name": "General Chat", 14 + "description": "Talk about anything.", 15 + "sortOrder": 1, 16 + "categoryUri": "{{category_uri}}" 17 + } 18 + } 19 + 20 + assert { 21 + res.status: eq 201 22 + res.body.uri: isDefined 23 + res.body.cid: isDefined 24 + } 25 + 26 + docs { 27 + Create a new forum board within a category. Fetches the category's CID from DB 28 + to build the categoryRef strongRef. Writes space.atbb.forum.board to the Forum 29 + DID's PDS. The firehose indexer creates the DB row asynchronously. 30 + 31 + **Requires:** space.atbb.permission.manageCategories 32 + 33 + Body: 34 + - name (required): Board display name 35 + - categoryUri (required): AT URI of the parent category 36 + - description (optional): Short description 37 + - sortOrder (optional): Numeric sort position (lower = first) 38 + 39 + Returns (201): 40 + { 41 + "uri": "at://did:plc:.../space.atbb.forum.board/abc123", 42 + "cid": "bafyrei..." 43 + } 44 + 45 + Error codes: 46 + - 400: Missing or empty name, missing categoryUri, malformed JSON 47 + - 401: Not authenticated 48 + - 403: Missing manageCategories permission 49 + - 404: categoryUri references unknown category 50 + - 500: ForumAgent not configured 51 + - 503: PDS network error 52 + }
+43
bruno/AppView API/Admin/Delete Board.bru
··· 1 + meta { 2 + name: Delete Board 3 + type: http 4 + seq: 15 5 + } 6 + 7 + delete { 8 + url: {{appview_url}}/api/admin/boards/:id 9 + } 10 + 11 + params:path { 12 + id: {{board_id}} 13 + } 14 + 15 + assert { 16 + res.status: eq 200 17 + res.body.success: isTrue 18 + } 19 + 20 + docs { 21 + Delete a forum board. Pre-flight check refuses with 409 if any posts reference 22 + this board. If clear, calls deleteRecord on the Forum DID's PDS. 23 + The firehose indexer removes the DB row asynchronously. 24 + 25 + **Requires:** space.atbb.permission.manageCategories 26 + 27 + Path params: 28 + - id: Board database ID (bigint as string) 29 + 30 + Returns (200): 31 + { 32 + "success": true 33 + } 34 + 35 + Error codes: 36 + - 400: Invalid ID format 37 + - 401: Not authenticated 38 + - 403: Missing manageCategories permission 39 + - 404: Board not found 40 + - 409: Board has posts — remove them first 41 + - 500: ForumAgent not configured 42 + - 503: PDS network error 43 + }
+58
bruno/AppView API/Admin/Update Board.bru
··· 1 + meta { 2 + name: Update Board 3 + type: http 4 + seq: 14 5 + } 6 + 7 + put { 8 + url: {{appview_url}}/api/admin/boards/:id 9 + } 10 + 11 + params:path { 12 + id: {{board_id}} 13 + } 14 + 15 + body:json { 16 + { 17 + "name": "General Chat (renamed)", 18 + "description": "Updated description.", 19 + "sortOrder": 2 20 + } 21 + } 22 + 23 + assert { 24 + res.status: eq 200 25 + res.body.uri: isDefined 26 + res.body.cid: isDefined 27 + } 28 + 29 + docs { 30 + Update an existing forum board's name, description, and sortOrder. 31 + Fetches existing rkey and categoryRef from DB, calls putRecord with updated 32 + fields preserving the original category and createdAt. Category cannot be 33 + changed on edit (no reparenting). 34 + 35 + **Requires:** space.atbb.permission.manageCategories 36 + 37 + Path params: 38 + - id: Board database ID (bigint as string) 39 + 40 + Body: 41 + - name (required): New display name 42 + - description (optional): New description (falls back to existing if omitted) 43 + - sortOrder (optional): New sort position (falls back to existing if omitted) 44 + 45 + Returns (200): 46 + { 47 + "uri": "at://did:plc:.../space.atbb.forum.board/abc123", 48 + "cid": "bafyrei..." 49 + } 50 + 51 + Error codes: 52 + - 400: Missing name, empty name, invalid ID format, malformed JSON 53 + - 401: Not authenticated 54 + - 403: Missing manageCategories permission 55 + - 404: Board not found 56 + - 500: ForumAgent not configured 57 + - 503: PDS network error 58 + }
+1108
docs/plans/complete/2026-02-28-atb-44-category-management-endpoints.md
··· 1 + # ATB-44: Admin Panel Category Management AppView Endpoints 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add `POST /api/admin/categories`, `PUT /api/admin/categories/:id`, and `DELETE /api/admin/categories/:id` to the AppView. 6 + 7 + **Architecture:** All mutations follow the PDS-first write pattern — validate input, do DB pre-flight checks (reads only), write to Forum DID's PDS via `ForumAgent`, return the AT URI + CID from the PDS response. The firehose indexer handles the DB row update asynchronously. No direct DB writes in the mutation path. 8 + 9 + **Tech Stack:** Hono, Drizzle ORM (postgres.js/libsql), `@atproto/api` (`AtpAgent`), `@atproto/common-web` (`TID`), Vitest, Bruno 10 + 11 + --- 12 + 13 + ## Background 14 + 15 + All code for these endpoints lives in `apps/appview/src/routes/admin.ts`. 16 + Tests live in `apps/appview/src/routes/__tests__/admin.test.ts`. 17 + Bruno docs live in `bruno/AppView API/Admin/`. 18 + 19 + ### Key helpers already in scope (admin.ts already imports these): 20 + - `safeParseJsonBody(c)` — parses JSON body, returns `{ body, error }` (400 on malformed JSON) 21 + - `getForumAgentOrError(ctx, c, operation)` — returns `{ agent, error }` (500 if null, 503 if unauthenticated) 22 + - `handleReadError(c, error, msg, ctx)` — classifies DB read errors (503 DB, 503 network, 500 other) 23 + - `handleWriteError(c, error, msg, ctx)` — classifies PDS write errors (503 network, 500 other) 24 + - `requireAuth(ctx)` middleware — returns 401 if not authenticated, sets `c.get("user")` 25 + - `requirePermission(ctx, permission)` middleware — returns 403 if user lacks permission 26 + 27 + ### Imports to ADD to admin.ts: 28 + ```typescript 29 + import { TID } from "@atproto/common-web"; 30 + import { parseBigIntParam } from "./helpers.js"; 31 + // Add to the @atbb/db import: categories, boards 32 + // count is already imported from drizzle-orm 33 + ``` 34 + 35 + ### The category lexicon (`space.atbb.forum.category`): 36 + ``` 37 + Required: name (string, max 100 graphemes), createdAt (datetime) 38 + Optional: description (string), slug (string), sortOrder (integer ≥ 0) 39 + ``` 40 + 41 + ### DB schema for categories (packages/db/src/schema.ts): 42 + ```typescript 43 + categories: { id: bigserial, did, rkey, cid, name, description, slug, sortOrder, forumId, createdAt, indexedAt } 44 + boards: { id: bigserial, did, rkey, cid, name, ..., categoryId (FK → categories.id), categoryUri } 45 + ``` 46 + 47 + ### Test mock setup pattern: 48 + ```typescript 49 + // In beforeEach — extend the ForumAgent mock to include deleteRecord 50 + let mockDeleteRecord: ReturnType<typeof vi.fn>; 51 + mockDeleteRecord = vi.fn().mockResolvedValue({}); 52 + 53 + ctx.forumAgent = { 54 + getAgent: () => ({ 55 + com: { 56 + atproto: { 57 + repo: { 58 + putRecord: mockPutRecord, 59 + deleteRecord: mockDeleteRecord, 60 + }, 61 + }, 62 + }, 63 + }), 64 + } as any; 65 + ``` 66 + 67 + ### How tests verify 401: 68 + Set `mockUser = null` before the request — the `requireAuth` mock returns 401 when `mockUser` is null. 69 + 70 + ### Run command: 71 + ```bash 72 + PATH=/path/to/monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 73 + ``` 74 + 75 + --- 76 + 77 + ## Task 1: Add failing tests for POST /api/admin/categories 78 + 79 + **Files:** 80 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 81 + 82 + **Step 1: Add a new `describe` block for the create endpoint** 83 + 84 + Add this after the final closing `});` of the `describe("GET /api/admin/members/me", ...)` block (before the final `});` that closes `describe.sequential("Admin Routes", ...)`): 85 + 86 + ```typescript 87 + describe("POST /api/admin/categories", () => { 88 + beforeEach(async () => { 89 + await ctx.cleanDatabase(); 90 + 91 + await ctx.db.insert(forums).values({ 92 + did: ctx.config.forumDid, 93 + rkey: "self", 94 + cid: "bafytest", 95 + name: "Test Forum", 96 + description: "A test forum", 97 + indexedAt: new Date(), 98 + }); 99 + 100 + mockUser = { did: "did:plc:test-admin" }; 101 + mockPutRecord.mockClear(); 102 + mockPutRecord.mockResolvedValue({ data: { uri: "at://did:plc:test-forum/space.atbb.forum.category/tid123", cid: "bafycategory" } }); 103 + }); 104 + 105 + it("creates category with valid body → 201 and putRecord called", async () => { 106 + const res = await app.request("/api/admin/categories", { 107 + method: "POST", 108 + headers: { "Content-Type": "application/json" }, 109 + body: JSON.stringify({ name: "General Discussion", description: "Talk about anything.", sortOrder: 1 }), 110 + }); 111 + 112 + expect(res.status).toBe(201); 113 + const data = await res.json(); 114 + expect(data.uri).toBeDefined(); 115 + expect(data.cid).toBeDefined(); 116 + expect(mockPutRecord).toHaveBeenCalledWith( 117 + expect.objectContaining({ 118 + repo: ctx.config.forumDid, 119 + collection: "space.atbb.forum.category", 120 + record: expect.objectContaining({ 121 + $type: "space.atbb.forum.category", 122 + name: "General Discussion", 123 + description: "Talk about anything.", 124 + sortOrder: 1, 125 + createdAt: expect.any(String), 126 + }), 127 + }) 128 + ); 129 + }); 130 + 131 + it("creates category without optional fields → 201", async () => { 132 + const res = await app.request("/api/admin/categories", { 133 + method: "POST", 134 + headers: { "Content-Type": "application/json" }, 135 + body: JSON.stringify({ name: "Minimal" }), 136 + }); 137 + 138 + expect(res.status).toBe(201); 139 + expect(mockPutRecord).toHaveBeenCalledWith( 140 + expect.objectContaining({ 141 + record: expect.objectContaining({ name: "Minimal" }), 142 + }) 143 + ); 144 + }); 145 + 146 + it("returns 400 when name is missing → no PDS write", async () => { 147 + const res = await app.request("/api/admin/categories", { 148 + method: "POST", 149 + headers: { "Content-Type": "application/json" }, 150 + body: JSON.stringify({ description: "No name field" }), 151 + }); 152 + 153 + expect(res.status).toBe(400); 154 + const data = await res.json(); 155 + expect(data.error).toContain("name"); 156 + expect(mockPutRecord).not.toHaveBeenCalled(); 157 + }); 158 + 159 + it("returns 400 when name is empty string → no PDS write", async () => { 160 + const res = await app.request("/api/admin/categories", { 161 + method: "POST", 162 + headers: { "Content-Type": "application/json" }, 163 + body: JSON.stringify({ name: " " }), 164 + }); 165 + 166 + expect(res.status).toBe(400); 167 + expect(mockPutRecord).not.toHaveBeenCalled(); 168 + }); 169 + 170 + it("returns 400 for malformed JSON", async () => { 171 + const res = await app.request("/api/admin/categories", { 172 + method: "POST", 173 + headers: { "Content-Type": "application/json" }, 174 + body: "{ bad json }", 175 + }); 176 + 177 + expect(res.status).toBe(400); 178 + const data = await res.json(); 179 + expect(data.error).toContain("Invalid JSON"); 180 + expect(mockPutRecord).not.toHaveBeenCalled(); 181 + }); 182 + 183 + it("returns 401 when unauthenticated → no PDS write", async () => { 184 + mockUser = null; 185 + 186 + const res = await app.request("/api/admin/categories", { 187 + method: "POST", 188 + headers: { "Content-Type": "application/json" }, 189 + body: JSON.stringify({ name: "Test" }), 190 + }); 191 + 192 + expect(res.status).toBe(401); 193 + expect(mockPutRecord).not.toHaveBeenCalled(); 194 + }); 195 + 196 + it("returns 503 when PDS network error", async () => { 197 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 198 + 199 + const res = await app.request("/api/admin/categories", { 200 + method: "POST", 201 + headers: { "Content-Type": "application/json" }, 202 + body: JSON.stringify({ name: "Test" }), 203 + }); 204 + 205 + expect(res.status).toBe(503); 206 + const data = await res.json(); 207 + expect(data.error).toContain("Unable to reach external service"); 208 + }); 209 + 210 + it("returns 500 when ForumAgent unavailable", async () => { 211 + ctx.forumAgent = null; 212 + 213 + const res = await app.request("/api/admin/categories", { 214 + method: "POST", 215 + headers: { "Content-Type": "application/json" }, 216 + body: JSON.stringify({ name: "Test" }), 217 + }); 218 + 219 + expect(res.status).toBe(500); 220 + const data = await res.json(); 221 + expect(data.error).toContain("Forum agent not available"); 222 + }); 223 + }); 224 + ``` 225 + 226 + **Step 2: Run tests to confirm they fail** 227 + 228 + ```bash 229 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30 230 + ``` 231 + 232 + Expected: `POST /api/admin/categories` tests fail with 404 (route not found). 233 + 234 + --- 235 + 236 + ## Task 2: Implement POST /api/admin/categories 237 + 238 + **Files:** 239 + - Modify: `apps/appview/src/routes/admin.ts` 240 + 241 + **Step 1: Add missing imports at the top of admin.ts** 242 + 243 + The current import line is: 244 + ```typescript 245 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors } from "@atbb/db"; 246 + ``` 247 + 248 + Change it to: 249 + ```typescript 250 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards } from "@atbb/db"; 251 + ``` 252 + 253 + Also add at the top of the file (after existing imports): 254 + ```typescript 255 + import { TID } from "@atproto/common-web"; 256 + import { parseBigIntParam } from "./helpers.js"; 257 + ``` 258 + 259 + **Step 2: Add the POST /api/admin/categories handler** 260 + 261 + Add the following inside the `createAdminRoutes` function, before the `return app;` line: 262 + 263 + ```typescript 264 + /** 265 + * POST /api/admin/categories 266 + * 267 + * Create a new forum category. Writes space.atbb.forum.category to Forum DID's PDS. 268 + * The firehose indexer creates the DB row asynchronously. 269 + */ 270 + app.post( 271 + "/categories", 272 + requireAuth(ctx), 273 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 274 + async (c) => { 275 + const { body, error: parseError } = await safeParseJsonBody(c); 276 + if (parseError) return parseError; 277 + 278 + const { name, description, sortOrder } = body; 279 + 280 + if (typeof name !== "string" || name.trim().length === 0) { 281 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 282 + } 283 + 284 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/categories"); 285 + if (agentError) return agentError; 286 + 287 + const rkey = TID.nextStr(); 288 + const now = new Date().toISOString(); 289 + 290 + try { 291 + const result = await agent.com.atproto.repo.putRecord({ 292 + repo: ctx.config.forumDid, 293 + collection: "space.atbb.forum.category", 294 + rkey, 295 + record: { 296 + $type: "space.atbb.forum.category", 297 + name: name.trim(), 298 + ...(typeof description === "string" && { description: description.trim() }), 299 + ...(typeof sortOrder === "number" && { sortOrder }), 300 + createdAt: now, 301 + }, 302 + }); 303 + 304 + return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 305 + } catch (error) { 306 + return handleWriteError(c, error, "Failed to create category", { 307 + operation: "POST /api/admin/categories", 308 + logger: ctx.logger, 309 + }); 310 + } 311 + } 312 + ); 313 + ``` 314 + 315 + **Step 3: Run tests** 316 + 317 + ```bash 318 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30 319 + ``` 320 + 321 + Expected: All `POST /api/admin/categories` tests pass. 322 + 323 + **Step 4: Commit** 324 + 325 + ```bash 326 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 327 + git commit -m "feat(appview): POST /api/admin/categories create endpoint (ATB-44)" 328 + ``` 329 + 330 + --- 331 + 332 + ## Task 3: Add failing tests for PUT /api/admin/categories/:id 333 + 334 + **Files:** 335 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 336 + 337 + **Step 1: Add the `describe` block** 338 + 339 + Add a new `describe` block (after the POST block, before the final `});`). Note that we need `categories` in scope — add it to the import at line 5: 340 + 341 + ```typescript 342 + import { memberships, roles, rolePermissions, users, forums, categories } from "@atbb/db"; 343 + ``` 344 + 345 + Then add: 346 + 347 + ```typescript 348 + describe.sequential("PUT /api/admin/categories/:id", () => { 349 + let categoryId: string; 350 + 351 + beforeEach(async () => { 352 + await ctx.cleanDatabase(); 353 + 354 + await ctx.db.insert(forums).values({ 355 + did: ctx.config.forumDid, 356 + rkey: "self", 357 + cid: "bafytest", 358 + name: "Test Forum", 359 + description: "A test forum", 360 + indexedAt: new Date(), 361 + }); 362 + 363 + const [cat] = await ctx.db.insert(categories).values({ 364 + did: ctx.config.forumDid, 365 + rkey: "tid-test-cat", 366 + cid: "bafycat", 367 + name: "Original Name", 368 + description: "Original description", 369 + sortOrder: 1, 370 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 371 + indexedAt: new Date(), 372 + }).returning({ id: categories.id }); 373 + 374 + categoryId = cat.id.toString(); 375 + 376 + mockUser = { did: "did:plc:test-admin" }; 377 + mockPutRecord.mockClear(); 378 + mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`, cid: "bafynewcid" } }); 379 + }); 380 + 381 + it("updates category name → 200 and putRecord called with same rkey", async () => { 382 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 383 + method: "PUT", 384 + headers: { "Content-Type": "application/json" }, 385 + body: JSON.stringify({ name: "Updated Name", description: "New desc", sortOrder: 2 }), 386 + }); 387 + 388 + expect(res.status).toBe(200); 389 + const data = await res.json(); 390 + expect(data.uri).toBeDefined(); 391 + expect(data.cid).toBeDefined(); 392 + expect(mockPutRecord).toHaveBeenCalledWith( 393 + expect.objectContaining({ 394 + repo: ctx.config.forumDid, 395 + collection: "space.atbb.forum.category", 396 + rkey: "tid-test-cat", // same rkey as existing category 397 + record: expect.objectContaining({ 398 + $type: "space.atbb.forum.category", 399 + name: "Updated Name", 400 + description: "New desc", 401 + sortOrder: 2, 402 + }), 403 + }) 404 + ); 405 + }); 406 + 407 + it("preserves original createdAt on update", async () => { 408 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 409 + method: "PUT", 410 + headers: { "Content-Type": "application/json" }, 411 + body: JSON.stringify({ name: "Updated Name" }), 412 + }); 413 + 414 + expect(res.status).toBe(200); 415 + expect(mockPutRecord).toHaveBeenCalledWith( 416 + expect.objectContaining({ 417 + record: expect.objectContaining({ 418 + createdAt: "2026-01-01T00:00:00.000Z", 419 + }), 420 + }) 421 + ); 422 + }); 423 + 424 + it("returns 400 when name is missing", async () => { 425 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 426 + method: "PUT", 427 + headers: { "Content-Type": "application/json" }, 428 + body: JSON.stringify({ description: "No name" }), 429 + }); 430 + 431 + expect(res.status).toBe(400); 432 + expect(mockPutRecord).not.toHaveBeenCalled(); 433 + }); 434 + 435 + it("returns 400 for invalid category ID", async () => { 436 + const res = await app.request("/api/admin/categories/not-a-number", { 437 + method: "PUT", 438 + headers: { "Content-Type": "application/json" }, 439 + body: JSON.stringify({ name: "Test" }), 440 + }); 441 + 442 + expect(res.status).toBe(400); 443 + const data = await res.json(); 444 + expect(data.error).toContain("Invalid category ID"); 445 + expect(mockPutRecord).not.toHaveBeenCalled(); 446 + }); 447 + 448 + it("returns 404 when category not found", async () => { 449 + const res = await app.request("/api/admin/categories/99999", { 450 + method: "PUT", 451 + headers: { "Content-Type": "application/json" }, 452 + body: JSON.stringify({ name: "Test" }), 453 + }); 454 + 455 + expect(res.status).toBe(404); 456 + expect(mockPutRecord).not.toHaveBeenCalled(); 457 + }); 458 + 459 + it("returns 401 when unauthenticated", async () => { 460 + mockUser = null; 461 + 462 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 463 + method: "PUT", 464 + headers: { "Content-Type": "application/json" }, 465 + body: JSON.stringify({ name: "Test" }), 466 + }); 467 + 468 + expect(res.status).toBe(401); 469 + expect(mockPutRecord).not.toHaveBeenCalled(); 470 + }); 471 + 472 + it("returns 503 when PDS network error", async () => { 473 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 474 + 475 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 476 + method: "PUT", 477 + headers: { "Content-Type": "application/json" }, 478 + body: JSON.stringify({ name: "Test" }), 479 + }); 480 + 481 + expect(res.status).toBe(503); 482 + }); 483 + 484 + it("returns 500 when ForumAgent unavailable", async () => { 485 + ctx.forumAgent = null; 486 + 487 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 488 + method: "PUT", 489 + headers: { "Content-Type": "application/json" }, 490 + body: JSON.stringify({ name: "Test" }), 491 + }); 492 + 493 + expect(res.status).toBe(500); 494 + }); 495 + }); 496 + ``` 497 + 498 + **Step 2: Run tests to confirm they fail** 499 + 500 + ```bash 501 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30 502 + ``` 503 + 504 + Expected: PUT tests fail with 404 (route not found). 505 + 506 + --- 507 + 508 + ## Task 4: Implement PUT /api/admin/categories/:id 509 + 510 + **Files:** 511 + - Modify: `apps/appview/src/routes/admin.ts` 512 + 513 + **Step 1: Add the PUT /api/admin/categories/:id handler** 514 + 515 + Add the following inside `createAdminRoutes`, after the POST /categories handler and before `return app;`: 516 + 517 + ```typescript 518 + /** 519 + * PUT /api/admin/categories/:id 520 + * 521 + * Update an existing category. Fetches existing rkey from DB, calls putRecord 522 + * with updated fields preserving the original createdAt. 523 + */ 524 + app.put( 525 + "/categories/:id", 526 + requireAuth(ctx), 527 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 528 + async (c) => { 529 + const idParam = c.req.param("id"); 530 + const id = parseBigIntParam(idParam); 531 + if (id === null) { 532 + return c.json({ error: "Invalid category ID" }, 400); 533 + } 534 + 535 + const { body, error: parseError } = await safeParseJsonBody(c); 536 + if (parseError) return parseError; 537 + 538 + const { name, description, sortOrder } = body; 539 + 540 + if (typeof name !== "string" || name.trim().length === 0) { 541 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 542 + } 543 + 544 + let category: typeof categories.$inferSelect; 545 + try { 546 + const [row] = await ctx.db 547 + .select() 548 + .from(categories) 549 + .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid))) 550 + .limit(1); 551 + 552 + if (!row) { 553 + return c.json({ error: "Category not found" }, 404); 554 + } 555 + category = row; 556 + } catch (error) { 557 + return handleReadError(c, error, "Failed to look up category", { 558 + operation: "PUT /api/admin/categories/:id", 559 + logger: ctx.logger, 560 + id: idParam, 561 + }); 562 + } 563 + 564 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/categories/:id"); 565 + if (agentError) return agentError; 566 + 567 + try { 568 + const result = await agent.com.atproto.repo.putRecord({ 569 + repo: ctx.config.forumDid, 570 + collection: "space.atbb.forum.category", 571 + rkey: category.rkey, 572 + record: { 573 + $type: "space.atbb.forum.category", 574 + name: name.trim(), 575 + ...(typeof description === "string" && { description: description.trim() }), 576 + ...(typeof sortOrder === "number" && { sortOrder }), 577 + createdAt: category.createdAt.toISOString(), 578 + }, 579 + }); 580 + 581 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 582 + } catch (error) { 583 + return handleWriteError(c, error, "Failed to update category", { 584 + operation: "PUT /api/admin/categories/:id", 585 + logger: ctx.logger, 586 + id: idParam, 587 + }); 588 + } 589 + } 590 + ); 591 + ``` 592 + 593 + **Step 2: Run tests** 594 + 595 + ```bash 596 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30 597 + ``` 598 + 599 + Expected: All PUT tests pass. 600 + 601 + **Step 3: Commit** 602 + 603 + ```bash 604 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 605 + git commit -m "feat(appview): PUT /api/admin/categories/:id update endpoint (ATB-44)" 606 + ``` 607 + 608 + --- 609 + 610 + ## Task 5: Add failing tests for DELETE /api/admin/categories/:id 611 + 612 + **Files:** 613 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 614 + 615 + **Step 1: Add `boards` to the import at line 5** 616 + 617 + ```typescript 618 + import { memberships, roles, rolePermissions, users, forums, categories, boards } from "@atbb/db"; 619 + ``` 620 + 621 + **Step 2: Update the top-level `beforeEach` to include `mockDeleteRecord`** 622 + 623 + The existing outer `beforeEach` has: 624 + ```typescript 625 + mockPutRecord = vi.fn().mockResolvedValue({ uri: "at://...", cid: "bafytest" }); 626 + ``` 627 + 628 + Change the ForumAgent mock to also expose `deleteRecord`: 629 + ```typescript 630 + mockPutRecord = vi.fn().mockResolvedValue({ data: { uri: "at://...", cid: "bafytest" } }); 631 + const mockDeleteRecord = vi.fn().mockResolvedValue({}); 632 + 633 + ctx.forumAgent = { 634 + getAgent: () => ({ 635 + com: { 636 + atproto: { 637 + repo: { 638 + putRecord: mockPutRecord, 639 + deleteRecord: mockDeleteRecord, 640 + }, 641 + }, 642 + }, 643 + }), 644 + } as any; 645 + ``` 646 + 647 + Wait — `mockDeleteRecord` needs to be accessible inside the DELETE describe block. Promote it to module level alongside `mockPutRecord`: 648 + 649 + At module level (near line 10): 650 + ```typescript 651 + let mockDeleteRecord: ReturnType<typeof vi.fn>; 652 + ``` 653 + 654 + Then in the outer `beforeEach`: 655 + ```typescript 656 + mockDeleteRecord = vi.fn().mockResolvedValue({}); 657 + ``` 658 + 659 + And update the ForumAgent mock shape in the outer `beforeEach` to include it. 660 + 661 + **Step 3: Add the DELETE describe block** 662 + 663 + ```typescript 664 + describe.sequential("DELETE /api/admin/categories/:id", () => { 665 + let categoryId: string; 666 + 667 + beforeEach(async () => { 668 + await ctx.cleanDatabase(); 669 + 670 + await ctx.db.insert(forums).values({ 671 + did: ctx.config.forumDid, 672 + rkey: "self", 673 + cid: "bafytest", 674 + name: "Test Forum", 675 + description: "A test forum", 676 + indexedAt: new Date(), 677 + }); 678 + 679 + const [cat] = await ctx.db.insert(categories).values({ 680 + did: ctx.config.forumDid, 681 + rkey: "tid-test-del", 682 + cid: "bafycat", 683 + name: "Delete Me", 684 + description: null, 685 + sortOrder: 1, 686 + createdAt: new Date(), 687 + indexedAt: new Date(), 688 + }).returning({ id: categories.id }); 689 + 690 + categoryId = cat.id.toString(); 691 + 692 + mockUser = { did: "did:plc:test-admin" }; 693 + mockDeleteRecord.mockClear(); 694 + mockDeleteRecord.mockResolvedValue({}); 695 + }); 696 + 697 + it("deletes empty category → 200 and deleteRecord called", async () => { 698 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 699 + method: "DELETE", 700 + }); 701 + 702 + expect(res.status).toBe(200); 703 + const data = await res.json(); 704 + expect(data.success).toBe(true); 705 + expect(mockDeleteRecord).toHaveBeenCalledWith({ 706 + repo: ctx.config.forumDid, 707 + collection: "space.atbb.forum.category", 708 + rkey: "tid-test-del", 709 + }); 710 + }); 711 + 712 + it("returns 409 when category has boards → deleteRecord NOT called", async () => { 713 + // Insert a category for the board to reference 714 + const [cat] = await ctx.db.select({ id: categories.id }) 715 + .from(categories) 716 + .where(eq(categories.rkey, "tid-test-del")) 717 + .limit(1); 718 + 719 + await ctx.db.insert(boards).values({ 720 + did: ctx.config.forumDid, 721 + rkey: "tid-board-1", 722 + cid: "bafyboard", 723 + name: "Blocked Board", 724 + categoryId: cat.id, 725 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-del`, 726 + createdAt: new Date(), 727 + indexedAt: new Date(), 728 + }); 729 + 730 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 731 + method: "DELETE", 732 + }); 733 + 734 + expect(res.status).toBe(409); 735 + const data = await res.json(); 736 + expect(data.error).toContain("boards"); 737 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 738 + }); 739 + 740 + it("returns 400 for invalid category ID", async () => { 741 + const res = await app.request("/api/admin/categories/not-a-number", { 742 + method: "DELETE", 743 + }); 744 + 745 + expect(res.status).toBe(400); 746 + const data = await res.json(); 747 + expect(data.error).toContain("Invalid category ID"); 748 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 749 + }); 750 + 751 + it("returns 404 when category not found", async () => { 752 + const res = await app.request("/api/admin/categories/99999", { 753 + method: "DELETE", 754 + }); 755 + 756 + expect(res.status).toBe(404); 757 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 758 + }); 759 + 760 + it("returns 401 when unauthenticated", async () => { 761 + mockUser = null; 762 + 763 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 764 + method: "DELETE", 765 + }); 766 + 767 + expect(res.status).toBe(401); 768 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 769 + }); 770 + 771 + it("returns 503 when PDS network error on delete", async () => { 772 + mockDeleteRecord.mockRejectedValue(new Error("fetch failed")); 773 + 774 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 775 + method: "DELETE", 776 + }); 777 + 778 + expect(res.status).toBe(503); 779 + }); 780 + 781 + it("returns 500 when ForumAgent unavailable", async () => { 782 + ctx.forumAgent = null; 783 + 784 + const res = await app.request(`/api/admin/categories/${categoryId}`, { 785 + method: "DELETE", 786 + }); 787 + 788 + expect(res.status).toBe(500); 789 + }); 790 + }); 791 + ``` 792 + 793 + **Step 4: Run tests to confirm they fail** 794 + 795 + ```bash 796 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30 797 + ``` 798 + 799 + Expected: DELETE tests fail with 404 (route not found). 800 + 801 + --- 802 + 803 + ## Task 6: Implement DELETE /api/admin/categories/:id 804 + 805 + **Files:** 806 + - Modify: `apps/appview/src/routes/admin.ts` 807 + 808 + **Step 1: Add the DELETE /api/admin/categories/:id handler** 809 + 810 + Add after the PUT handler, before `return app;`: 811 + 812 + ```typescript 813 + /** 814 + * DELETE /api/admin/categories/:id 815 + * 816 + * Delete a category. Pre-flight: refuses with 409 if any boards reference this 817 + * category in the DB. If clear, calls deleteRecord on the Forum DID's PDS. 818 + * The firehose indexer removes the DB row asynchronously. 819 + */ 820 + app.delete( 821 + "/categories/:id", 822 + requireAuth(ctx), 823 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 824 + async (c) => { 825 + const idParam = c.req.param("id"); 826 + const id = parseBigIntParam(idParam); 827 + if (id === null) { 828 + return c.json({ error: "Invalid category ID" }, 400); 829 + } 830 + 831 + let category: typeof categories.$inferSelect; 832 + try { 833 + const [row] = await ctx.db 834 + .select() 835 + .from(categories) 836 + .where(and(eq(categories.id, id), eq(categories.did, ctx.config.forumDid))) 837 + .limit(1); 838 + 839 + if (!row) { 840 + return c.json({ error: "Category not found" }, 404); 841 + } 842 + category = row; 843 + } catch (error) { 844 + return handleReadError(c, error, "Failed to look up category", { 845 + operation: "DELETE /api/admin/categories/:id", 846 + logger: ctx.logger, 847 + id: idParam, 848 + }); 849 + } 850 + 851 + // Pre-flight: refuse if any boards reference this category 852 + try { 853 + const [boardCount] = await ctx.db 854 + .select({ count: count() }) 855 + .from(boards) 856 + .where(eq(boards.categoryId, id)); 857 + 858 + if (boardCount && boardCount.count > 0) { 859 + return c.json( 860 + { error: "Cannot delete category with boards. Remove all boards first." }, 861 + 409 862 + ); 863 + } 864 + } catch (error) { 865 + return handleReadError(c, error, "Failed to check category boards", { 866 + operation: "DELETE /api/admin/categories/:id", 867 + logger: ctx.logger, 868 + id: idParam, 869 + }); 870 + } 871 + 872 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/categories/:id"); 873 + if (agentError) return agentError; 874 + 875 + try { 876 + await agent.com.atproto.repo.deleteRecord({ 877 + repo: ctx.config.forumDid, 878 + collection: "space.atbb.forum.category", 879 + rkey: category.rkey, 880 + }); 881 + 882 + return c.json({ success: true }); 883 + } catch (error) { 884 + return handleWriteError(c, error, "Failed to delete category", { 885 + operation: "DELETE /api/admin/categories/:id", 886 + logger: ctx.logger, 887 + id: idParam, 888 + }); 889 + } 890 + } 891 + ); 892 + ``` 893 + 894 + **Step 2: Run all tests** 895 + 896 + ```bash 897 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -30 898 + ``` 899 + 900 + Expected: All tests pass. 901 + 902 + **Step 3: Run full test suite** 903 + 904 + ```bash 905 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview test 2>&1 | tail -20 906 + ``` 907 + 908 + Expected: All pass. 909 + 910 + **Step 4: Commit** 911 + 912 + ```bash 913 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 914 + git commit -m "feat(appview): DELETE /api/admin/categories/:id delete endpoint (ATB-44)" 915 + ``` 916 + 917 + --- 918 + 919 + ## Task 7: Add Bruno API collection files 920 + 921 + **Files:** 922 + - Create: `bruno/AppView API/Admin/Create Category.bru` 923 + - Create: `bruno/AppView API/Admin/Update Category.bru` 924 + - Create: `bruno/AppView API/Admin/Delete Category.bru` 925 + 926 + **Step 1: Create the three .bru files** 927 + 928 + `bruno/AppView API/Admin/Create Category.bru`: 929 + ``` 930 + meta { 931 + name: Create Category 932 + type: http 933 + seq: 10 934 + } 935 + 936 + post { 937 + url: {{appview_url}}/api/admin/categories 938 + } 939 + 940 + body:json { 941 + { 942 + "name": "General Discussion", 943 + "description": "Talk about anything.", 944 + "sortOrder": 1 945 + } 946 + } 947 + 948 + assert { 949 + res.status: eq 201 950 + res.body.uri: isDefined 951 + res.body.cid: isDefined 952 + } 953 + 954 + docs { 955 + Create a new forum category. Writes space.atbb.forum.category to the Forum DID's PDS. 956 + The firehose indexer creates the DB row asynchronously. 957 + 958 + **Requires:** space.atbb.permission.manageCategories 959 + 960 + Body: 961 + - name (required): Category display name 962 + - description (optional): Short description 963 + - sortOrder (optional): Numeric sort position (lower = first) 964 + 965 + Returns (201): 966 + { 967 + "uri": "at://did:plc:.../space.atbb.forum.category/abc123", 968 + "cid": "bafyrei..." 969 + } 970 + 971 + Error codes: 972 + - 400: Missing or empty name, malformed JSON 973 + - 401: Not authenticated 974 + - 403: Missing manageCategories permission 975 + - 500: ForumAgent not configured 976 + - 503: PDS network error 977 + } 978 + ``` 979 + 980 + `bruno/AppView API/Admin/Update Category.bru`: 981 + ``` 982 + meta { 983 + name: Update Category 984 + type: http 985 + seq: 11 986 + } 987 + 988 + put { 989 + url: {{appview_url}}/api/admin/categories/:id 990 + } 991 + 992 + params:path { 993 + id: {{category_id}} 994 + } 995 + 996 + body:json { 997 + { 998 + "name": "Updated Name", 999 + "description": "Updated description.", 1000 + "sortOrder": 2 1001 + } 1002 + } 1003 + 1004 + assert { 1005 + res.status: eq 200 1006 + res.body.uri: isDefined 1007 + res.body.cid: isDefined 1008 + } 1009 + 1010 + docs { 1011 + Update an existing forum category. Fetches existing rkey from DB, calls putRecord 1012 + with updated fields preserving the original createdAt. 1013 + 1014 + **Requires:** space.atbb.permission.manageCategories 1015 + 1016 + Path params: 1017 + - id: Category database ID (bigint as string) 1018 + 1019 + Body: 1020 + - name (required): New display name 1021 + - description (optional): New description 1022 + - sortOrder (optional): New sort position 1023 + 1024 + Returns (200): 1025 + { 1026 + "uri": "at://did:plc:.../space.atbb.forum.category/abc123", 1027 + "cid": "bafyrei..." 1028 + } 1029 + 1030 + Error codes: 1031 + - 400: Missing name, empty name, invalid ID format, malformed JSON 1032 + - 401: Not authenticated 1033 + - 403: Missing manageCategories permission 1034 + - 404: Category not found 1035 + - 500: ForumAgent not configured 1036 + - 503: PDS network error 1037 + } 1038 + ``` 1039 + 1040 + `bruno/AppView API/Admin/Delete Category.bru`: 1041 + ``` 1042 + meta { 1043 + name: Delete Category 1044 + type: http 1045 + seq: 12 1046 + } 1047 + 1048 + delete { 1049 + url: {{appview_url}}/api/admin/categories/:id 1050 + } 1051 + 1052 + params:path { 1053 + id: {{category_id}} 1054 + } 1055 + 1056 + assert { 1057 + res.status: eq 200 1058 + res.body.success: isTrue 1059 + } 1060 + 1061 + docs { 1062 + Delete a forum category. Pre-flight check refuses with 409 if any boards reference 1063 + this category. If clear, calls deleteRecord on the Forum DID's PDS. 1064 + The firehose indexer removes the DB row asynchronously. 1065 + 1066 + **Requires:** space.atbb.permission.manageCategories 1067 + 1068 + Path params: 1069 + - id: Category database ID (bigint as string) 1070 + 1071 + Returns (200): 1072 + { 1073 + "success": true 1074 + } 1075 + 1076 + Error codes: 1077 + - 400: Invalid ID format 1078 + - 401: Not authenticated 1079 + - 403: Missing manageCategories permission 1080 + - 404: Category not found 1081 + - 409: Category has boards — remove them first 1082 + - 500: ForumAgent not configured 1083 + - 503: PDS network error 1084 + } 1085 + ``` 1086 + 1087 + **Step 2: Commit** 1088 + 1089 + ```bash 1090 + git add "bruno/AppView API/Admin/Create Category.bru" \ 1091 + "bruno/AppView API/Admin/Update Category.bru" \ 1092 + "bruno/AppView API/Admin/Delete Category.bru" 1093 + git commit -m "docs(bruno): add category management API collection (ATB-44)" 1094 + ``` 1095 + 1096 + --- 1097 + 1098 + ## Final verification 1099 + 1100 + ```bash 1101 + # Run full test suite 1102 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview test 1103 + 1104 + # Lint fix 1105 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview lint:fix 1106 + ``` 1107 + 1108 + Then update Linear ATB-44 to Done and mark items complete in `docs/atproto-forum-plan.md`.
+1327
docs/plans/complete/2026-02-28-atb-45-board-management-endpoints.md
··· 1 + # Board Management Endpoints Implementation Plan (ATB-45) 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add `POST /api/admin/boards`, `PUT /api/admin/boards/:id`, and `DELETE /api/admin/boards/:id` endpoints to the AppView, following the same PDS-first write pattern as category management. 6 + 7 + **Architecture:** All mutations write to the Forum DID's PDS via `ForumAgent.putRecord()`/`deleteRecord()`; the firehose indexer handles DB updates asynchronously. The only synchronous DB read is looking up the category CID on create (needed to build the `categoryRef` strongRef required by the lexicon). Delete pre-flights against `posts.boardId` to refuse with 409 if posts exist. 8 + 9 + **Tech Stack:** Hono, Drizzle ORM (postgres.js), `@atproto/common-web` (TID generation), Vitest, Bruno. 10 + 11 + --- 12 + 13 + ## Key files to understand before starting 14 + 15 + - **Implementation:** `apps/appview/src/routes/admin.ts` — add the 3 new endpoints at the bottom (before `return app`) 16 + - **Tests:** `apps/appview/src/routes/__tests__/admin.test.ts` — add a new `describe.sequential("POST /api/admin/boards"...)` block after the categories tests (around line 1450) 17 + - **DB schema:** `packages/db/src/schema.ts` — `boards` table has `id`, `did`, `rkey`, `cid`, `name`, `description`, `slug`, `sortOrder`, `categoryId`, `categoryUri`, `createdAt`, `indexedAt` 18 + - **Lexicon:** `packages/lexicon/lexicons/space/atbb/forum/board.yaml` — record has `name`, `description`, `slug`, `sortOrder`, `category` (a `categoryRef` object: `{ category: { uri, cid } }`) 19 + - **Route helpers:** `apps/appview/src/lib/route-errors.ts` — `handleRouteError`, `safeParseJsonBody`, `getForumAgentOrError` 20 + - **Bruno templates:** `bruno/AppView API/Admin/Create Category.bru`, `Update Category.bru`, `Delete Category.bru` — copy as starting point 21 + 22 + ## Test setup pattern (copy from categories tests) 23 + 24 + The test file already has mocks at the top for `requireAuth`, `requirePermission`, `mockPutRecord`, and `mockDeleteRecord`. The board tests reuse these same mocks. No new setup needed — just add new `describe.sequential` blocks after the existing categories describe blocks. 25 + 26 + For tests that need a category in the DB (create + edit lookups), insert with: 27 + ```typescript 28 + const [cat] = await ctx.db.insert(categories).values({ 29 + did: ctx.config.forumDid, 30 + rkey: "tid-test-cat", 31 + cid: "bafycat", 32 + name: "Test Category", 33 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 34 + indexedAt: new Date(), 35 + }).returning({ id: categories.id }); 36 + const categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 37 + ``` 38 + 39 + For tests that need a board in the DB (edit + delete), insert with: 40 + ```typescript 41 + const [brd] = await ctx.db.insert(boards).values({ 42 + did: ctx.config.forumDid, 43 + rkey: "tid-test-board", 44 + cid: "bafyboard", 45 + name: "Original Name", 46 + description: "Original description", 47 + sortOrder: 1, 48 + categoryId: cat.id, 49 + categoryUri, 50 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 51 + indexedAt: new Date(), 52 + }).returning({ id: boards.id }); 53 + const boardId = brd.id.toString(); 54 + ``` 55 + 56 + --- 57 + 58 + ## Task 1: POST /api/admin/boards — failing tests 59 + 60 + **Files:** 61 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` (append after line ~1450, after the closing `});` of the DELETE categories describe block) 62 + 63 + **Step 1: Write the failing tests** 64 + 65 + Add this new describe block to the end of the `describe.sequential("Admin Routes", ...)` block (just before the outer closing `});`): 66 + 67 + ```typescript 68 + describe.sequential("POST /api/admin/boards", () => { 69 + let categoryUri: string; 70 + 71 + beforeEach(async () => { 72 + await ctx.cleanDatabase(); 73 + 74 + mockUser = { did: "did:plc:test-admin" }; 75 + mockPutRecord.mockClear(); 76 + mockDeleteRecord.mockClear(); 77 + mockPutRecord.mockResolvedValue({ 78 + data: { 79 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid123`, 80 + cid: "bafyboard", 81 + }, 82 + }); 83 + 84 + // Insert a category the tests can reference 85 + await ctx.db.insert(categories).values({ 86 + did: ctx.config.forumDid, 87 + rkey: "tid-test-cat", 88 + cid: "bafycat", 89 + name: "Test Category", 90 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 91 + indexedAt: new Date(), 92 + }); 93 + categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 94 + }); 95 + 96 + it("creates board with valid body → 201 and putRecord called with categoryRef", async () => { 97 + const res = await app.request("/api/admin/boards", { 98 + method: "POST", 99 + headers: { "Content-Type": "application/json" }, 100 + body: JSON.stringify({ name: "General Chat", description: "Talk here.", sortOrder: 1, categoryUri }), 101 + }); 102 + 103 + expect(res.status).toBe(201); 104 + const data = await res.json(); 105 + expect(data.uri).toContain("/space.atbb.forum.board/"); 106 + expect(data.cid).toBe("bafyboard"); 107 + expect(mockPutRecord).toHaveBeenCalledWith( 108 + expect.objectContaining({ 109 + repo: ctx.config.forumDid, 110 + collection: "space.atbb.forum.board", 111 + rkey: expect.any(String), 112 + record: expect.objectContaining({ 113 + $type: "space.atbb.forum.board", 114 + name: "General Chat", 115 + description: "Talk here.", 116 + sortOrder: 1, 117 + category: { category: { uri: categoryUri, cid: "bafycat" } }, 118 + createdAt: expect.any(String), 119 + }), 120 + }) 121 + ); 122 + }); 123 + 124 + it("creates board without optional fields → 201", async () => { 125 + const res = await app.request("/api/admin/boards", { 126 + method: "POST", 127 + headers: { "Content-Type": "application/json" }, 128 + body: JSON.stringify({ name: "Minimal", categoryUri }), 129 + }); 130 + 131 + expect(res.status).toBe(201); 132 + expect(mockPutRecord).toHaveBeenCalledWith( 133 + expect.objectContaining({ 134 + record: expect.objectContaining({ name: "Minimal" }), 135 + }) 136 + ); 137 + }); 138 + 139 + it("returns 400 when name is missing → no PDS write", async () => { 140 + const res = await app.request("/api/admin/boards", { 141 + method: "POST", 142 + headers: { "Content-Type": "application/json" }, 143 + body: JSON.stringify({ categoryUri }), 144 + }); 145 + 146 + expect(res.status).toBe(400); 147 + const data = await res.json(); 148 + expect(data.error).toContain("name"); 149 + expect(mockPutRecord).not.toHaveBeenCalled(); 150 + }); 151 + 152 + it("returns 400 when name is empty string → no PDS write", async () => { 153 + const res = await app.request("/api/admin/boards", { 154 + method: "POST", 155 + headers: { "Content-Type": "application/json" }, 156 + body: JSON.stringify({ name: " ", categoryUri }), 157 + }); 158 + 159 + expect(res.status).toBe(400); 160 + expect(mockPutRecord).not.toHaveBeenCalled(); 161 + }); 162 + 163 + it("returns 400 when categoryUri is missing → no PDS write", async () => { 164 + const res = await app.request("/api/admin/boards", { 165 + method: "POST", 166 + headers: { "Content-Type": "application/json" }, 167 + body: JSON.stringify({ name: "Test Board" }), 168 + }); 169 + 170 + expect(res.status).toBe(400); 171 + const data = await res.json(); 172 + expect(data.error).toContain("categoryUri"); 173 + expect(mockPutRecord).not.toHaveBeenCalled(); 174 + }); 175 + 176 + it("returns 404 when categoryUri references unknown category → no PDS write", async () => { 177 + const res = await app.request("/api/admin/boards", { 178 + method: "POST", 179 + headers: { "Content-Type": "application/json" }, 180 + body: JSON.stringify({ name: "Test Board", categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/unknown999` }), 181 + }); 182 + 183 + expect(res.status).toBe(404); 184 + const data = await res.json(); 185 + expect(data.error).toContain("Category not found"); 186 + expect(mockPutRecord).not.toHaveBeenCalled(); 187 + }); 188 + 189 + it("returns 400 for malformed JSON", async () => { 190 + const res = await app.request("/api/admin/boards", { 191 + method: "POST", 192 + headers: { "Content-Type": "application/json" }, 193 + body: "{ bad json }", 194 + }); 195 + 196 + expect(res.status).toBe(400); 197 + const data = await res.json(); 198 + expect(data.error).toContain("Invalid JSON"); 199 + expect(mockPutRecord).not.toHaveBeenCalled(); 200 + }); 201 + 202 + it("returns 401 when unauthenticated → no PDS write", async () => { 203 + mockUser = null; 204 + 205 + const res = await app.request("/api/admin/boards", { 206 + method: "POST", 207 + headers: { "Content-Type": "application/json" }, 208 + body: JSON.stringify({ name: "Test", categoryUri }), 209 + }); 210 + 211 + expect(res.status).toBe(401); 212 + expect(mockPutRecord).not.toHaveBeenCalled(); 213 + }); 214 + 215 + it("returns 503 when PDS network error", async () => { 216 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 217 + 218 + const res = await app.request("/api/admin/boards", { 219 + method: "POST", 220 + headers: { "Content-Type": "application/json" }, 221 + body: JSON.stringify({ name: "Test", categoryUri }), 222 + }); 223 + 224 + expect(res.status).toBe(503); 225 + const data = await res.json(); 226 + expect(data.error).toContain("Unable to reach external service"); 227 + expect(mockPutRecord).toHaveBeenCalled(); 228 + }); 229 + 230 + it("returns 500 when ForumAgent unavailable", async () => { 231 + ctx.forumAgent = null; 232 + 233 + const res = await app.request("/api/admin/boards", { 234 + method: "POST", 235 + headers: { "Content-Type": "application/json" }, 236 + body: JSON.stringify({ name: "Test", categoryUri }), 237 + }); 238 + 239 + expect(res.status).toBe(500); 240 + const data = await res.json(); 241 + expect(data.error).toContain("Forum agent not available"); 242 + }); 243 + 244 + it("returns 403 when user lacks manageCategories permission", async () => { 245 + const { requirePermission } = await import("../../middleware/permissions.js"); 246 + const mockRequirePermission = requirePermission as any; 247 + mockRequirePermission.mockImplementation(() => async (c: any) => { 248 + return c.json({ error: "Forbidden" }, 403); 249 + }); 250 + 251 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 252 + const res = await testApp.request("/api/admin/boards", { 253 + method: "POST", 254 + headers: { "Content-Type": "application/json" }, 255 + body: JSON.stringify({ name: "Test", categoryUri }), 256 + }); 257 + 258 + expect(res.status).toBe(403); 259 + expect(mockPutRecord).not.toHaveBeenCalled(); 260 + 261 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 262 + await next(); 263 + }); 264 + }); 265 + }); 266 + ``` 267 + 268 + **Step 2: Run tests to verify they fail** 269 + 270 + ```bash 271 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 272 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 273 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PASS|Error|✓|×|POST.*boards" | head -30 274 + ``` 275 + 276 + Expected: Tests fail with "Cannot find route" or similar. 277 + 278 + **Step 3: Commit the failing tests** 279 + 280 + ```bash 281 + git add apps/appview/src/routes/__tests__/admin.test.ts 282 + git commit -m "test(appview): add failing tests for POST /api/admin/boards (ATB-45)" 283 + ``` 284 + 285 + --- 286 + 287 + ## Task 2: POST /api/admin/boards — implementation 288 + 289 + **Files:** 290 + - Modify: `apps/appview/src/routes/admin.ts` (add before `return app;` at the end, around line 701) 291 + 292 + **Step 1: Add the POST /api/admin/boards endpoint** 293 + 294 + Insert the following handler before the `return app;` line: 295 + 296 + ```typescript 297 + /** 298 + * POST /api/admin/boards 299 + * 300 + * Create a new forum board within a category. Fetches the category's CID from DB 301 + * to build the categoryRef strongRef required by the lexicon. Writes 302 + * space.atbb.forum.board to the Forum DID's PDS via putRecord. 303 + * The firehose indexer creates the DB row asynchronously. 304 + */ 305 + app.post( 306 + "/boards", 307 + requireAuth(ctx), 308 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 309 + async (c) => { 310 + const { body, error: parseError } = await safeParseJsonBody(c); 311 + if (parseError) return parseError; 312 + 313 + const { name, description, sortOrder, categoryUri } = body; 314 + 315 + if (typeof name !== "string" || name.trim().length === 0) { 316 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 317 + } 318 + 319 + if (typeof categoryUri !== "string" || !categoryUri.startsWith("at://")) { 320 + return c.json({ error: "categoryUri is required and must be a valid AT URI" }, 400); 321 + } 322 + 323 + // Derive rkey from the categoryUri to look up the category in the DB 324 + const categoryRkey = categoryUri.split("/").pop(); 325 + 326 + let category: typeof categories.$inferSelect; 327 + try { 328 + const [row] = await ctx.db 329 + .select() 330 + .from(categories) 331 + .where( 332 + and( 333 + eq(categories.did, ctx.config.forumDid), 334 + eq(categories.rkey, categoryRkey ?? "") 335 + ) 336 + ) 337 + .limit(1); 338 + 339 + if (!row) { 340 + return c.json({ error: "Category not found" }, 404); 341 + } 342 + category = row; 343 + } catch (error) { 344 + return handleRouteError(c, error, "Failed to look up category", { 345 + operation: "POST /api/admin/boards", 346 + logger: ctx.logger, 347 + categoryUri, 348 + }); 349 + } 350 + 351 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/boards"); 352 + if (agentError) return agentError; 353 + 354 + const rkey = TID.nextStr(); 355 + const now = new Date().toISOString(); 356 + 357 + try { 358 + const result = await agent.com.atproto.repo.putRecord({ 359 + repo: ctx.config.forumDid, 360 + collection: "space.atbb.forum.board", 361 + rkey, 362 + record: { 363 + $type: "space.atbb.forum.board", 364 + name: name.trim(), 365 + ...(typeof description === "string" && { description: description.trim() }), 366 + ...(Number.isInteger(sortOrder) && sortOrder >= 0 && { sortOrder }), 367 + category: { category: { uri: categoryUri, cid: category.cid } }, 368 + createdAt: now, 369 + }, 370 + }); 371 + 372 + return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 373 + } catch (error) { 374 + return handleRouteError(c, error, "Failed to create board", { 375 + operation: "POST /api/admin/boards", 376 + logger: ctx.logger, 377 + categoryUri, 378 + }); 379 + } 380 + } 381 + ); 382 + ``` 383 + 384 + **Step 2: Run tests to verify they pass** 385 + 386 + ```bash 387 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 388 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 389 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PASS|POST.*boards|✓|×" | head -30 390 + ``` 391 + 392 + Expected: All POST /api/admin/boards tests PASS. 393 + 394 + **Step 3: Commit** 395 + 396 + ```bash 397 + git add apps/appview/src/routes/admin.ts 398 + git commit -m "feat(appview): POST /api/admin/boards create endpoint (ATB-45)" 399 + ``` 400 + 401 + --- 402 + 403 + ## Task 3: PUT /api/admin/boards/:id — failing tests 404 + 405 + **Files:** 406 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` (append after the POST boards describe block) 407 + 408 + **Step 1: Write the failing tests** 409 + 410 + ```typescript 411 + describe.sequential("PUT /api/admin/boards/:id", () => { 412 + let boardId: string; 413 + let categoryUri: string; 414 + 415 + beforeEach(async () => { 416 + await ctx.cleanDatabase(); 417 + 418 + mockUser = { did: "did:plc:test-admin" }; 419 + mockPutRecord.mockClear(); 420 + mockDeleteRecord.mockClear(); 421 + mockPutRecord.mockResolvedValue({ 422 + data: { 423 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`, 424 + cid: "bafyboardupdated", 425 + }, 426 + }); 427 + 428 + // Insert a category and a board 429 + const [cat] = await ctx.db.insert(categories).values({ 430 + did: ctx.config.forumDid, 431 + rkey: "tid-test-cat", 432 + cid: "bafycat", 433 + name: "Test Category", 434 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 435 + indexedAt: new Date(), 436 + }).returning({ id: categories.id }); 437 + 438 + categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 439 + 440 + const [brd] = await ctx.db.insert(boards).values({ 441 + did: ctx.config.forumDid, 442 + rkey: "tid-test-board", 443 + cid: "bafyboard", 444 + name: "Original Name", 445 + description: "Original description", 446 + sortOrder: 1, 447 + categoryId: cat.id, 448 + categoryUri, 449 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 450 + indexedAt: new Date(), 451 + }).returning({ id: boards.id }); 452 + 453 + boardId = brd.id.toString(); 454 + }); 455 + 456 + it("updates board with all fields → 200 and putRecord called with same rkey", async () => { 457 + const res = await app.request(`/api/admin/boards/${boardId}`, { 458 + method: "PUT", 459 + headers: { "Content-Type": "application/json" }, 460 + body: JSON.stringify({ name: "Renamed Board", description: "New description", sortOrder: 2 }), 461 + }); 462 + 463 + expect(res.status).toBe(200); 464 + const data = await res.json(); 465 + expect(data.uri).toContain("/space.atbb.forum.board/"); 466 + expect(data.cid).toBe("bafyboardupdated"); 467 + expect(mockPutRecord).toHaveBeenCalledWith( 468 + expect.objectContaining({ 469 + repo: ctx.config.forumDid, 470 + collection: "space.atbb.forum.board", 471 + rkey: "tid-test-board", 472 + record: expect.objectContaining({ 473 + $type: "space.atbb.forum.board", 474 + name: "Renamed Board", 475 + description: "New description", 476 + sortOrder: 2, 477 + category: { category: { uri: categoryUri, cid: "bafycat" } }, 478 + }), 479 + }) 480 + ); 481 + }); 482 + 483 + it("updates board without optional fields → falls back to existing values", async () => { 484 + const res = await app.request(`/api/admin/boards/${boardId}`, { 485 + method: "PUT", 486 + headers: { "Content-Type": "application/json" }, 487 + body: JSON.stringify({ name: "Renamed Only" }), 488 + }); 489 + 490 + expect(res.status).toBe(200); 491 + expect(mockPutRecord).toHaveBeenCalledWith( 492 + expect.objectContaining({ 493 + record: expect.objectContaining({ 494 + name: "Renamed Only", 495 + description: "Original description", 496 + sortOrder: 1, 497 + }), 498 + }) 499 + ); 500 + }); 501 + 502 + it("returns 400 when name is missing", async () => { 503 + const res = await app.request(`/api/admin/boards/${boardId}`, { 504 + method: "PUT", 505 + headers: { "Content-Type": "application/json" }, 506 + body: JSON.stringify({ description: "No name" }), 507 + }); 508 + 509 + expect(res.status).toBe(400); 510 + const data = await res.json(); 511 + expect(data.error).toContain("name"); 512 + expect(mockPutRecord).not.toHaveBeenCalled(); 513 + }); 514 + 515 + it("returns 400 when name is empty string", async () => { 516 + const res = await app.request(`/api/admin/boards/${boardId}`, { 517 + method: "PUT", 518 + headers: { "Content-Type": "application/json" }, 519 + body: JSON.stringify({ name: " " }), 520 + }); 521 + 522 + expect(res.status).toBe(400); 523 + expect(mockPutRecord).not.toHaveBeenCalled(); 524 + }); 525 + 526 + it("returns 400 for non-numeric ID", async () => { 527 + const res = await app.request("/api/admin/boards/not-a-number", { 528 + method: "PUT", 529 + headers: { "Content-Type": "application/json" }, 530 + body: JSON.stringify({ name: "Test" }), 531 + }); 532 + 533 + expect(res.status).toBe(400); 534 + expect(mockPutRecord).not.toHaveBeenCalled(); 535 + }); 536 + 537 + it("returns 404 when board not found", async () => { 538 + const res = await app.request("/api/admin/boards/99999", { 539 + method: "PUT", 540 + headers: { "Content-Type": "application/json" }, 541 + body: JSON.stringify({ name: "Test" }), 542 + }); 543 + 544 + expect(res.status).toBe(404); 545 + const data = await res.json(); 546 + expect(data.error).toContain("Board not found"); 547 + expect(mockPutRecord).not.toHaveBeenCalled(); 548 + }); 549 + 550 + it("returns 400 for malformed JSON", async () => { 551 + const res = await app.request(`/api/admin/boards/${boardId}`, { 552 + method: "PUT", 553 + headers: { "Content-Type": "application/json" }, 554 + body: "{ bad json }", 555 + }); 556 + 557 + expect(res.status).toBe(400); 558 + expect(mockPutRecord).not.toHaveBeenCalled(); 559 + }); 560 + 561 + it("returns 401 when unauthenticated", async () => { 562 + mockUser = null; 563 + 564 + const res = await app.request(`/api/admin/boards/${boardId}`, { 565 + method: "PUT", 566 + headers: { "Content-Type": "application/json" }, 567 + body: JSON.stringify({ name: "Test" }), 568 + }); 569 + 570 + expect(res.status).toBe(401); 571 + expect(mockPutRecord).not.toHaveBeenCalled(); 572 + }); 573 + 574 + it("returns 503 when PDS network error", async () => { 575 + mockPutRecord.mockRejectedValue(new Error("fetch failed")); 576 + 577 + const res = await app.request(`/api/admin/boards/${boardId}`, { 578 + method: "PUT", 579 + headers: { "Content-Type": "application/json" }, 580 + body: JSON.stringify({ name: "Test" }), 581 + }); 582 + 583 + expect(res.status).toBe(503); 584 + const data = await res.json(); 585 + expect(data.error).toContain("Unable to reach external service"); 586 + }); 587 + 588 + it("returns 500 when ForumAgent unavailable", async () => { 589 + ctx.forumAgent = null; 590 + 591 + const res = await app.request(`/api/admin/boards/${boardId}`, { 592 + method: "PUT", 593 + headers: { "Content-Type": "application/json" }, 594 + body: JSON.stringify({ name: "Test" }), 595 + }); 596 + 597 + expect(res.status).toBe(500); 598 + const data = await res.json(); 599 + expect(data.error).toContain("Forum agent not available"); 600 + }); 601 + 602 + it("returns 403 when user lacks manageCategories permission", async () => { 603 + const { requirePermission } = await import("../../middleware/permissions.js"); 604 + const mockRequirePermission = requirePermission as any; 605 + mockRequirePermission.mockImplementation(() => async (c: any) => { 606 + return c.json({ error: "Forbidden" }, 403); 607 + }); 608 + 609 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 610 + const res = await testApp.request(`/api/admin/boards/${boardId}`, { 611 + method: "PUT", 612 + headers: { "Content-Type": "application/json" }, 613 + body: JSON.stringify({ name: "Test" }), 614 + }); 615 + 616 + expect(res.status).toBe(403); 617 + expect(mockPutRecord).not.toHaveBeenCalled(); 618 + 619 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 620 + await next(); 621 + }); 622 + }); 623 + }); 624 + ``` 625 + 626 + **Step 2: Run tests to verify they fail** 627 + 628 + ```bash 629 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 630 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 631 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PUT.*boards|×" | head -20 632 + ``` 633 + 634 + Expected: PUT /api/admin/boards tests FAIL. 635 + 636 + **Step 3: Commit** 637 + 638 + ```bash 639 + git add apps/appview/src/routes/__tests__/admin.test.ts 640 + git commit -m "test(appview): add failing tests for PUT /api/admin/boards/:id (ATB-45)" 641 + ``` 642 + 643 + --- 644 + 645 + ## Task 4: PUT /api/admin/boards/:id — implementation 646 + 647 + **Files:** 648 + - Modify: `apps/appview/src/routes/admin.ts` (add after the POST /boards handler, before `return app;`) 649 + 650 + **Step 1: Add the PUT /api/admin/boards/:id endpoint** 651 + 652 + Note: The `boards` table stores `categoryUri` and `categoryId`. The edit endpoint re-uses the existing `categoryUri` and fetches the category CID. This avoids clients being able to secretly reparent a board by passing a new `categoryUri` on edit (category changes would need a dedicated reparent operation). 653 + 654 + ```typescript 655 + /** 656 + * PUT /api/admin/boards/:id 657 + * 658 + * Update an existing board's name, description, and sortOrder. 659 + * Fetches existing rkey + categoryUri from DB, then putRecord with updated fields. 660 + * Preserves the original categoryRef and createdAt. 661 + * The firehose indexer updates the DB row asynchronously. 662 + */ 663 + app.put( 664 + "/boards/:id", 665 + requireAuth(ctx), 666 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 667 + async (c) => { 668 + const idParam = c.req.param("id"); 669 + const id = parseBigIntParam(idParam); 670 + if (id === null) { 671 + return c.json({ error: "Invalid board ID" }, 400); 672 + } 673 + 674 + const { body, error: parseError } = await safeParseJsonBody(c); 675 + if (parseError) return parseError; 676 + 677 + const { name, description, sortOrder } = body; 678 + 679 + if (typeof name !== "string" || name.trim().length === 0) { 680 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 681 + } 682 + 683 + let board: typeof boards.$inferSelect; 684 + try { 685 + const [row] = await ctx.db 686 + .select() 687 + .from(boards) 688 + .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid))) 689 + .limit(1); 690 + 691 + if (!row) { 692 + return c.json({ error: "Board not found" }, 404); 693 + } 694 + board = row; 695 + } catch (error) { 696 + return handleRouteError(c, error, "Failed to look up board", { 697 + operation: "PUT /api/admin/boards/:id", 698 + logger: ctx.logger, 699 + id: idParam, 700 + }); 701 + } 702 + 703 + // Fetch category CID to build the categoryRef strongRef 704 + let categoryCid: string; 705 + try { 706 + const categoryRkey = board.categoryUri.split("/").pop() ?? ""; 707 + const [cat] = await ctx.db 708 + .select({ cid: categories.cid }) 709 + .from(categories) 710 + .where( 711 + and( 712 + eq(categories.did, ctx.config.forumDid), 713 + eq(categories.rkey, categoryRkey) 714 + ) 715 + ) 716 + .limit(1); 717 + 718 + if (!cat) { 719 + return c.json({ error: "Category not found" }, 404); 720 + } 721 + categoryCid = cat.cid; 722 + } catch (error) { 723 + return handleRouteError(c, error, "Failed to look up category", { 724 + operation: "PUT /api/admin/boards/:id", 725 + logger: ctx.logger, 726 + id: idParam, 727 + }); 728 + } 729 + 730 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/boards/:id"); 731 + if (agentError) return agentError; 732 + 733 + // putRecord is a full replacement — fall back to existing values for 734 + // optional fields not provided in the request body, to avoid data loss. 735 + const resolvedDescription = typeof description === "string" 736 + ? description.trim() 737 + : board.description; 738 + const resolvedSortOrder = (Number.isInteger(sortOrder) && sortOrder >= 0) 739 + ? sortOrder 740 + : board.sortOrder; 741 + 742 + try { 743 + const result = await agent.com.atproto.repo.putRecord({ 744 + repo: ctx.config.forumDid, 745 + collection: "space.atbb.forum.board", 746 + rkey: board.rkey, 747 + record: { 748 + $type: "space.atbb.forum.board", 749 + name: name.trim(), 750 + ...(resolvedDescription != null && { description: resolvedDescription }), 751 + ...(resolvedSortOrder != null && { sortOrder: resolvedSortOrder }), 752 + category: { category: { uri: board.categoryUri, cid: categoryCid } }, 753 + createdAt: board.createdAt.toISOString(), 754 + }, 755 + }); 756 + 757 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 758 + } catch (error) { 759 + return handleRouteError(c, error, "Failed to update board", { 760 + operation: "PUT /api/admin/boards/:id", 761 + logger: ctx.logger, 762 + id: idParam, 763 + }); 764 + } 765 + } 766 + ); 767 + ``` 768 + 769 + **Step 2: Run tests to verify they pass** 770 + 771 + ```bash 772 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 773 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 774 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|PASS|PUT.*boards|✓|×" | head -30 775 + ``` 776 + 777 + Expected: All PUT /api/admin/boards tests PASS. 778 + 779 + **Step 3: Commit** 780 + 781 + ```bash 782 + git add apps/appview/src/routes/admin.ts 783 + git commit -m "feat(appview): PUT /api/admin/boards/:id update endpoint (ATB-45)" 784 + ``` 785 + 786 + --- 787 + 788 + ## Task 5: DELETE /api/admin/boards/:id — failing tests 789 + 790 + **Files:** 791 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` (append after the PUT boards describe block) 792 + 793 + **Step 1: Write the failing tests** 794 + 795 + ```typescript 796 + describe.sequential("DELETE /api/admin/boards/:id", () => { 797 + let boardId: string; 798 + let categoryUri: string; 799 + 800 + beforeEach(async () => { 801 + await ctx.cleanDatabase(); 802 + 803 + mockUser = { did: "did:plc:test-admin" }; 804 + mockPutRecord.mockClear(); 805 + mockDeleteRecord.mockClear(); 806 + mockDeleteRecord.mockResolvedValue({}); 807 + 808 + // Insert a category and a board 809 + const [cat] = await ctx.db.insert(categories).values({ 810 + did: ctx.config.forumDid, 811 + rkey: "tid-test-cat", 812 + cid: "bafycat", 813 + name: "Test Category", 814 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 815 + indexedAt: new Date(), 816 + }).returning({ id: categories.id }); 817 + 818 + categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 819 + 820 + const [brd] = await ctx.db.insert(boards).values({ 821 + did: ctx.config.forumDid, 822 + rkey: "tid-test-board", 823 + cid: "bafyboard", 824 + name: "Test Board", 825 + categoryId: cat.id, 826 + categoryUri, 827 + createdAt: new Date("2026-01-01T00:00:00.000Z"), 828 + indexedAt: new Date(), 829 + }).returning({ id: boards.id }); 830 + 831 + boardId = brd.id.toString(); 832 + }); 833 + 834 + it("deletes empty board → 200 and deleteRecord called", async () => { 835 + const res = await app.request(`/api/admin/boards/${boardId}`, { 836 + method: "DELETE", 837 + }); 838 + 839 + expect(res.status).toBe(200); 840 + const data = await res.json(); 841 + expect(data.success).toBe(true); 842 + expect(mockDeleteRecord).toHaveBeenCalledWith( 843 + expect.objectContaining({ 844 + repo: ctx.config.forumDid, 845 + collection: "space.atbb.forum.board", 846 + rkey: "tid-test-board", 847 + }) 848 + ); 849 + }); 850 + 851 + it("returns 409 when board has posts → deleteRecord NOT called", async () => { 852 + // Insert a user and a post referencing this board 853 + await ctx.db.insert(users).values({ 854 + did: "did:plc:test-user", 855 + handle: "testuser.bsky.social", 856 + indexedAt: new Date(), 857 + }); 858 + 859 + const [brd] = await ctx.db.select().from(boards).where(eq(boards.rkey, "tid-test-board")).limit(1); 860 + 861 + // Insert a post with boardId set 862 + await ctx.db.insert(posts).values({ 863 + did: "did:plc:test-user", 864 + rkey: "tid-test-post", 865 + cid: "bafypost", 866 + text: "Hello world", 867 + boardId: brd.id, 868 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`, 869 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 870 + createdAt: new Date(), 871 + indexedAt: new Date(), 872 + }); 873 + 874 + const res = await app.request(`/api/admin/boards/${boardId}`, { 875 + method: "DELETE", 876 + }); 877 + 878 + expect(res.status).toBe(409); 879 + const data = await res.json(); 880 + expect(data.error).toContain("posts"); 881 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 882 + }); 883 + 884 + it("returns 400 for non-numeric ID", async () => { 885 + const res = await app.request("/api/admin/boards/not-a-number", { 886 + method: "DELETE", 887 + }); 888 + 889 + expect(res.status).toBe(400); 890 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 891 + }); 892 + 893 + it("returns 404 when board not found", async () => { 894 + const res = await app.request("/api/admin/boards/99999", { 895 + method: "DELETE", 896 + }); 897 + 898 + expect(res.status).toBe(404); 899 + const data = await res.json(); 900 + expect(data.error).toContain("Board not found"); 901 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 902 + }); 903 + 904 + it("returns 401 when unauthenticated", async () => { 905 + mockUser = null; 906 + 907 + const res = await app.request(`/api/admin/boards/${boardId}`, { 908 + method: "DELETE", 909 + }); 910 + 911 + expect(res.status).toBe(401); 912 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 913 + }); 914 + 915 + it("returns 503 when PDS network error", async () => { 916 + mockDeleteRecord.mockRejectedValue(new Error("fetch failed")); 917 + 918 + const res = await app.request(`/api/admin/boards/${boardId}`, { 919 + method: "DELETE", 920 + }); 921 + 922 + expect(res.status).toBe(503); 923 + const data = await res.json(); 924 + expect(data.error).toContain("Unable to reach external service"); 925 + }); 926 + 927 + it("returns 500 when ForumAgent unavailable", async () => { 928 + ctx.forumAgent = null; 929 + 930 + const res = await app.request(`/api/admin/boards/${boardId}`, { 931 + method: "DELETE", 932 + }); 933 + 934 + expect(res.status).toBe(500); 935 + const data = await res.json(); 936 + expect(data.error).toContain("Forum agent not available"); 937 + }); 938 + 939 + it("returns 403 when user lacks manageCategories permission", async () => { 940 + const { requirePermission } = await import("../../middleware/permissions.js"); 941 + const mockRequirePermission = requirePermission as any; 942 + mockRequirePermission.mockImplementation(() => async (c: any) => { 943 + return c.json({ error: "Forbidden" }, 403); 944 + }); 945 + 946 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 947 + const res = await testApp.request(`/api/admin/boards/${boardId}`, { 948 + method: "DELETE", 949 + }); 950 + 951 + expect(res.status).toBe(403); 952 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 953 + 954 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 955 + await next(); 956 + }); 957 + }); 958 + }); 959 + ``` 960 + 961 + **Important:** The DELETE test that inserts a post needs to import `posts` from `@atbb/db`. Check the import at the top of the test file — if `posts` is not already imported, add it to the existing import: 962 + 963 + ```typescript 964 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts } from "@atbb/db"; 965 + ``` 966 + 967 + **Step 2: Run tests to verify they fail** 968 + 969 + ```bash 970 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 971 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 972 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | grep -E "FAIL|DELETE.*boards|×" | head -20 973 + ``` 974 + 975 + Expected: DELETE /api/admin/boards tests FAIL. 976 + 977 + **Step 3: Commit** 978 + 979 + ```bash 980 + git add apps/appview/src/routes/__tests__/admin.test.ts 981 + git commit -m "test(appview): add failing tests for DELETE /api/admin/boards/:id (ATB-45)" 982 + ``` 983 + 984 + --- 985 + 986 + ## Task 6: DELETE /api/admin/boards/:id — implementation 987 + 988 + **Files:** 989 + - Modify: `apps/appview/src/routes/admin.ts` (add after the PUT /boards/:id handler, before `return app;`) 990 + 991 + **Step 1: Add the DELETE /api/admin/boards/:id endpoint** 992 + 993 + Also add `posts` to the existing Drizzle import at the top of the file: 994 + ```typescript 995 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts } from "@atbb/db"; 996 + ``` 997 + 998 + Then add the handler: 999 + 1000 + ```typescript 1001 + /** 1002 + * DELETE /api/admin/boards/:id 1003 + * 1004 + * Delete a board. Pre-flight: refuses with 409 if any posts have boardId 1005 + * pointing to this board. If clear, calls deleteRecord on the Forum DID's PDS. 1006 + * The firehose indexer removes the DB row asynchronously. 1007 + */ 1008 + app.delete( 1009 + "/boards/:id", 1010 + requireAuth(ctx), 1011 + requirePermission(ctx, "space.atbb.permission.manageCategories"), 1012 + async (c) => { 1013 + const idParam = c.req.param("id"); 1014 + const id = parseBigIntParam(idParam); 1015 + if (id === null) { 1016 + return c.json({ error: "Invalid board ID" }, 400); 1017 + } 1018 + 1019 + let board: typeof boards.$inferSelect; 1020 + try { 1021 + const [row] = await ctx.db 1022 + .select() 1023 + .from(boards) 1024 + .where(and(eq(boards.id, id), eq(boards.did, ctx.config.forumDid))) 1025 + .limit(1); 1026 + 1027 + if (!row) { 1028 + return c.json({ error: "Board not found" }, 404); 1029 + } 1030 + board = row; 1031 + } catch (error) { 1032 + return handleRouteError(c, error, "Failed to look up board", { 1033 + operation: "DELETE /api/admin/boards/:id", 1034 + logger: ctx.logger, 1035 + id: idParam, 1036 + }); 1037 + } 1038 + 1039 + // Pre-flight: refuse if any posts reference this board 1040 + try { 1041 + const [postCount] = await ctx.db 1042 + .select({ count: count() }) 1043 + .from(posts) 1044 + .where(eq(posts.boardId, id)); 1045 + 1046 + if (postCount && postCount.count > 0) { 1047 + return c.json( 1048 + { error: "Cannot delete board with posts. Remove all posts first." }, 1049 + 409 1050 + ); 1051 + } 1052 + } catch (error) { 1053 + return handleRouteError(c, error, "Failed to check board posts", { 1054 + operation: "DELETE /api/admin/boards/:id", 1055 + logger: ctx.logger, 1056 + id: idParam, 1057 + }); 1058 + } 1059 + 1060 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/boards/:id"); 1061 + if (agentError) return agentError; 1062 + 1063 + try { 1064 + await agent.com.atproto.repo.deleteRecord({ 1065 + repo: ctx.config.forumDid, 1066 + collection: "space.atbb.forum.board", 1067 + rkey: board.rkey, 1068 + }); 1069 + 1070 + return c.json({ success: true }); 1071 + } catch (error) { 1072 + return handleRouteError(c, error, "Failed to delete board", { 1073 + operation: "DELETE /api/admin/boards/:id", 1074 + logger: ctx.logger, 1075 + id: idParam, 1076 + }); 1077 + } 1078 + } 1079 + ); 1080 + ``` 1081 + 1082 + **Step 2: Run all admin tests to verify everything passes** 1083 + 1084 + ```bash 1085 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 1086 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 1087 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 2>&1 | tail -20 1088 + ``` 1089 + 1090 + Expected: All tests PASS, no failures. 1091 + 1092 + **Step 3: Run the full test suite** 1093 + 1094 + ```bash 1095 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 1096 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 1097 + pnpm --filter @atbb/appview exec vitest run 2>&1 | tail -20 1098 + ``` 1099 + 1100 + Expected: All tests PASS. 1101 + 1102 + **Step 4: Commit** 1103 + 1104 + ```bash 1105 + git add apps/appview/src/routes/admin.ts 1106 + git commit -m "feat(appview): DELETE /api/admin/boards/:id delete endpoint (ATB-45)" 1107 + ``` 1108 + 1109 + --- 1110 + 1111 + ## Task 7: Bruno collection — board management 1112 + 1113 + **Files:** 1114 + - Create: `bruno/AppView API/Admin/Create Board.bru` 1115 + - Create: `bruno/AppView API/Admin/Update Board.bru` 1116 + - Create: `bruno/AppView API/Admin/Delete Board.bru` 1117 + 1118 + **Step 1: Create Create Board.bru** 1119 + 1120 + ``` 1121 + meta { 1122 + name: Create Board 1123 + type: http 1124 + seq: 13 1125 + } 1126 + 1127 + post { 1128 + url: {{appview_url}}/api/admin/boards 1129 + } 1130 + 1131 + body:json { 1132 + { 1133 + "name": "General Chat", 1134 + "description": "Talk about anything.", 1135 + "sortOrder": 1, 1136 + "categoryUri": "{{category_uri}}" 1137 + } 1138 + } 1139 + 1140 + assert { 1141 + res.status: eq 201 1142 + res.body.uri: isDefined 1143 + res.body.cid: isDefined 1144 + } 1145 + 1146 + docs { 1147 + Create a new forum board within a category. Fetches the category's CID from DB 1148 + to build the categoryRef strongRef. Writes space.atbb.forum.board to the Forum 1149 + DID's PDS. The firehose indexer creates the DB row asynchronously. 1150 + 1151 + **Requires:** space.atbb.permission.manageCategories 1152 + 1153 + Body: 1154 + - name (required): Board display name 1155 + - categoryUri (required): AT URI of the parent category 1156 + - description (optional): Short description 1157 + - sortOrder (optional): Numeric sort position (lower = first) 1158 + 1159 + Returns (201): 1160 + { 1161 + "uri": "at://did:plc:.../space.atbb.forum.board/abc123", 1162 + "cid": "bafyrei..." 1163 + } 1164 + 1165 + Error codes: 1166 + - 400: Missing or empty name, missing categoryUri, malformed JSON 1167 + - 401: Not authenticated 1168 + - 403: Missing manageCategories permission 1169 + - 404: categoryUri references unknown category 1170 + - 500: ForumAgent not configured 1171 + - 503: PDS network error 1172 + } 1173 + ``` 1174 + 1175 + **Step 2: Create Update Board.bru** 1176 + 1177 + ``` 1178 + meta { 1179 + name: Update Board 1180 + type: http 1181 + seq: 14 1182 + } 1183 + 1184 + put { 1185 + url: {{appview_url}}/api/admin/boards/:id 1186 + } 1187 + 1188 + params:path { 1189 + id: {{board_id}} 1190 + } 1191 + 1192 + body:json { 1193 + { 1194 + "name": "General Chat (renamed)", 1195 + "description": "Updated description.", 1196 + "sortOrder": 2 1197 + } 1198 + } 1199 + 1200 + assert { 1201 + res.status: eq 200 1202 + res.body.uri: isDefined 1203 + res.body.cid: isDefined 1204 + } 1205 + 1206 + docs { 1207 + Update an existing forum board's name, description, and sortOrder. 1208 + Fetches existing rkey and categoryRef from DB, calls putRecord with updated 1209 + fields preserving the original category and createdAt. 1210 + 1211 + **Requires:** space.atbb.permission.manageCategories 1212 + 1213 + Path params: 1214 + - id: Board database ID (bigint as string) 1215 + 1216 + Body: 1217 + - name (required): New display name 1218 + - description (optional): New description (falls back to existing if omitted) 1219 + - sortOrder (optional): New sort position (falls back to existing if omitted) 1220 + 1221 + Returns (200): 1222 + { 1223 + "uri": "at://did:plc:.../space.atbb.forum.board/abc123", 1224 + "cid": "bafyrei..." 1225 + } 1226 + 1227 + Error codes: 1228 + - 400: Missing name, empty name, invalid ID format, malformed JSON 1229 + - 401: Not authenticated 1230 + - 403: Missing manageCategories permission 1231 + - 404: Board not found 1232 + - 500: ForumAgent not configured 1233 + - 503: PDS network error 1234 + } 1235 + ``` 1236 + 1237 + **Step 3: Create Delete Board.bru** 1238 + 1239 + ``` 1240 + meta { 1241 + name: Delete Board 1242 + type: http 1243 + seq: 15 1244 + } 1245 + 1246 + delete { 1247 + url: {{appview_url}}/api/admin/boards/:id 1248 + } 1249 + 1250 + params:path { 1251 + id: {{board_id}} 1252 + } 1253 + 1254 + assert { 1255 + res.status: eq 200 1256 + res.body.success: isTrue 1257 + } 1258 + 1259 + docs { 1260 + Delete a forum board. Pre-flight check refuses with 409 if any posts reference 1261 + this board. If clear, calls deleteRecord on the Forum DID's PDS. 1262 + The firehose indexer removes the DB row asynchronously. 1263 + 1264 + **Requires:** space.atbb.permission.manageCategories 1265 + 1266 + Path params: 1267 + - id: Board database ID (bigint as string) 1268 + 1269 + Returns (200): 1270 + { 1271 + "success": true 1272 + } 1273 + 1274 + Error codes: 1275 + - 400: Invalid ID format 1276 + - 401: Not authenticated 1277 + - 403: Missing manageCategories permission 1278 + - 404: Board not found 1279 + - 409: Board has posts — remove them first 1280 + - 500: ForumAgent not configured 1281 + - 503: PDS network error 1282 + } 1283 + ``` 1284 + 1285 + **Step 4: Commit** 1286 + 1287 + ```bash 1288 + git add "bruno/AppView API/Admin/Create Board.bru" "bruno/AppView API/Admin/Update Board.bru" "bruno/AppView API/Admin/Delete Board.bru" 1289 + git commit -m "docs(bruno): add board management API collection (ATB-45)" 1290 + ``` 1291 + 1292 + --- 1293 + 1294 + ## Task 8: Lint, full test run, and verification 1295 + 1296 + **Step 1: Run lint fix** 1297 + 1298 + ```bash 1299 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 1300 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 1301 + pnpm --filter @atbb/appview lint:fix 1302 + ``` 1303 + 1304 + Expected: No unfixable errors. 1305 + 1306 + **Step 2: Run full test suite** 1307 + 1308 + ```bash 1309 + export PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH 1310 + cd /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo 1311 + pnpm --filter @atbb/appview exec vitest run 2>&1 | tail -20 1312 + ``` 1313 + 1314 + Expected: All tests PASS. 1315 + 1316 + **Step 3: Update Linear issue** 1317 + 1318 + Mark ATB-45 as "In Progress" in Linear, then as "Done" once the branch is ready for review. 1319 + 1320 + **Step 4: Request code review** 1321 + 1322 + Follow the commit-push-pr skill to push and open a PR: 1323 + ```bash 1324 + git log --oneline origin/main..HEAD 1325 + ``` 1326 + 1327 + Verify all commits are present, then push and open PR against `main`.