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(web): admin structure management page — /admin/structure (ATB-47) (#80)

* feat(appview): add uri field to serializeCategory (ATB-47)

* test(web): add failing tests for GET /admin/structure (ATB-47)

* feat(web): add GET /admin/structure page with category/board listing (ATB-47)

* fix(web): use lowercase method="post" on structure page forms (ATB-47)

* test(web): add failing tests for category proxy routes (ATB-47)

* feat(web): add category proxy routes for structure management (ATB-47)

* test(web): add failing tests for board proxy routes (ATB-47)

* feat(web): add board proxy routes for structure management (ATB-47)

* feat(web): add CSS for admin structure management page (ATB-47)

* test(web): add missing network error tests for edit proxy routes (ATB-47)

* fix(web): log errors when boards fetch fails per-category in structure page (ATB-47)

* docs: add completed implementation plan for ATB-47 admin structure UI

* fix(web): validate sort order and fix board delete 409 test (ATB-47)

- parseSortOrder now returns null for negative/non-integer values and
redirects with "Sort order must be a non-negative integer." error;
0 remains the default for empty/missing sort order fields
- Use Number() + Number.isInteger() instead of parseInt() to reject
floats like "1.5" that parseInt would silently truncate
- Add validation tests for negative sort order across all 4 create/edit
handlers (create category, edit category, create board, edit board)
- Fix board delete 409 test mock to use the real AppView error message
("Cannot delete board with posts. Remove all posts first.") and assert
the message appears in the redirect URL, matching category delete test

authored by

Malpercio and committed by
GitHub
45b94bee 680debb7

+3786
+4
apps/appview/src/routes/__tests__/categories.test.ts
··· 80 80 expect(category).toHaveProperty("description"); 81 81 expect(category).toHaveProperty("slug"); 82 82 expect(category).toHaveProperty("sortOrder"); 83 + 84 + // Verify AT-URI field is present and well-formed 85 + expect(category.uri).toMatch(/^at:\/\/did:/); 86 + expect(category.uri).toContain("/space.atbb.forum.category/"); 83 87 }); 84 88 85 89 it("does not leak internal fields (rkey, cid)", async () => {
+1
apps/appview/src/routes/__tests__/helpers.test.ts
··· 497 497 expect(result).toEqual({ 498 498 id: "1", 499 499 did: "did:plc:forum", 500 + uri: "at://did:plc:forum/space.atbb.forum.category/3lbk9cat", 500 501 name: "General Discussion", 501 502 description: "A place for general conversation", 502 503 slug: "general",
+2
apps/appview/src/routes/helpers.ts
··· 268 268 * { 269 269 * "id": "1234", // BigInt → string 270 270 * "did": "did:plc:...", 271 + * "uri": "at://did:plc:.../space.atbb.forum.category/...", // computed AT URI 271 272 * "name": "General Discussion", 272 273 * "description": "A place for..." | null, 273 274 * "slug": "general" | null, ··· 282 283 return { 283 284 id: serializeBigInt(cat.id), 284 285 did: cat.did, 286 + uri: `at://${cat.did}/space.atbb.forum.category/${cat.rkey}`, 285 287 name: cat.name, 286 288 description: cat.description, 287 289 slug: cat.slug,
+152
apps/web/public/static/css/theme.css
··· 999 999 font-weight: var(--font-weight-bold); 1000 1000 margin-top: var(--space-xs); 1001 1001 } 1002 + 1003 + /* ── Structure Management Page ──────────────────────────────────────────── */ 1004 + 1005 + .structure-page { 1006 + display: flex; 1007 + flex-direction: column; 1008 + gap: var(--space-6, 1.5rem); 1009 + } 1010 + 1011 + .structure-category { 1012 + background: var(--color-surface); 1013 + border: var(--border-width) solid var(--color-border); 1014 + border-radius: var(--radius, 0.5rem); 1015 + overflow: hidden; 1016 + } 1017 + 1018 + .structure-category__header { 1019 + display: flex; 1020 + align-items: center; 1021 + gap: var(--space-sm); 1022 + padding: var(--space-sm) var(--space-md); 1023 + background: var(--color-bg); 1024 + border-bottom: var(--border-width) solid var(--color-border); 1025 + } 1026 + 1027 + .structure-category__name { 1028 + font-weight: var(--font-weight-bold); 1029 + font-size: var(--font-size-base); 1030 + flex: 1; 1031 + } 1032 + 1033 + .structure-category__meta { 1034 + font-size: var(--font-size-sm); 1035 + color: var(--color-text-muted); 1036 + } 1037 + 1038 + .structure-category__actions { 1039 + display: flex; 1040 + gap: var(--space-sm); 1041 + flex-shrink: 0; 1042 + } 1043 + 1044 + .structure-boards { 1045 + padding: var(--space-sm); 1046 + display: flex; 1047 + flex-direction: column; 1048 + gap: var(--space-sm); 1049 + } 1050 + 1051 + .structure-board { 1052 + background: var(--color-bg); 1053 + border: var(--border-width) solid var(--color-border); 1054 + border-radius: var(--radius, 0.375rem); 1055 + } 1056 + 1057 + .structure-board__header { 1058 + display: flex; 1059 + align-items: center; 1060 + gap: var(--space-sm); 1061 + padding: var(--space-xs) var(--space-sm); 1062 + } 1063 + 1064 + .structure-board__name { 1065 + font-weight: var(--font-weight-bold); 1066 + flex: 1; 1067 + } 1068 + 1069 + .structure-board__meta { 1070 + font-size: var(--font-size-sm); 1071 + color: var(--color-text-muted); 1072 + } 1073 + 1074 + .structure-board__actions { 1075 + display: flex; 1076 + gap: var(--space-sm); 1077 + flex-shrink: 0; 1078 + } 1079 + 1080 + .structure-edit-form { 1081 + border-top: var(--border-width) solid var(--color-border); 1082 + } 1083 + 1084 + .structure-edit-form[open] { 1085 + display: block; 1086 + } 1087 + 1088 + .structure-edit-form__body { 1089 + padding: var(--space-md); 1090 + display: flex; 1091 + flex-direction: column; 1092 + gap: var(--space-sm); 1093 + } 1094 + 1095 + .structure-add-board { 1096 + border: var(--border-width) dashed var(--color-border); 1097 + border-radius: var(--radius, 0.375rem); 1098 + background: transparent; 1099 + } 1100 + 1101 + .structure-add-board__trigger { 1102 + display: block; 1103 + padding: var(--space-xs) var(--space-sm); 1104 + cursor: pointer; 1105 + color: var(--color-primary); 1106 + font-size: var(--font-size-sm); 1107 + user-select: none; 1108 + } 1109 + 1110 + .structure-add-board__trigger:hover { 1111 + background: var(--color-surface); 1112 + border-radius: var(--radius, 0.375rem); 1113 + } 1114 + 1115 + .structure-add-category { 1116 + margin-top: var(--space-md); 1117 + } 1118 + 1119 + .structure-confirm-dialog { 1120 + border-radius: var(--radius, 0.5rem); 1121 + border: var(--border-width) solid var(--color-border); 1122 + padding: var(--space-6, 1.5rem); 1123 + max-width: 24rem; 1124 + } 1125 + 1126 + .structure-confirm-dialog::backdrop { 1127 + background: rgba(0, 0, 0, 0.4); 1128 + } 1129 + 1130 + .structure-confirm-dialog p { 1131 + margin-bottom: var(--space-md); 1132 + } 1133 + 1134 + .dialog-actions { 1135 + display: flex; 1136 + gap: var(--space-sm); 1137 + justify-content: flex-end; 1138 + } 1139 + 1140 + .structure-error-banner { 1141 + background: var(--color-surface); 1142 + color: var(--color-danger); 1143 + border: var(--border-width) solid var(--color-danger); 1144 + border-left-width: calc(var(--border-width) * 3); 1145 + border-radius: var(--radius, 0.5rem); 1146 + padding: var(--space-sm) var(--space-md); 1147 + margin-bottom: var(--space-md); 1148 + } 1149 + 1150 + .btn-sm { 1151 + font-size: var(--font-size-sm); 1152 + padding: var(--space-xs) var(--space-sm); 1153 + }
+962
apps/web/src/routes/__tests__/admin.test.tsx
··· 743 743 ); 744 744 }); 745 745 }); 746 + 747 + describe("createAdminRoutes — GET /admin/structure", () => { 748 + beforeEach(() => { 749 + vi.stubGlobal("fetch", mockFetch); 750 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 751 + vi.resetModules(); 752 + }); 753 + 754 + afterEach(() => { 755 + vi.unstubAllGlobals(); 756 + vi.unstubAllEnvs(); 757 + mockFetch.mockReset(); 758 + }); 759 + 760 + function mockResponse(body: unknown, ok = true, status = 200) { 761 + return { 762 + ok, 763 + status, 764 + statusText: ok ? "OK" : "Error", 765 + json: () => Promise.resolve(body), 766 + }; 767 + } 768 + 769 + function setupSession(permissions: string[]) { 770 + mockFetch.mockResolvedValueOnce( 771 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 772 + ); 773 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 774 + } 775 + 776 + /** 777 + * Sets up mock responses for the structure page data fetches. 778 + * After the 2 session calls: 779 + * Call 3: GET /api/categories 780 + * Call 4+: GET /api/categories/:id/boards (one per category, parallel) 781 + */ 782 + function setupStructureFetch( 783 + cats: Array<{ id: string; name: string; uri: string; sortOrder?: number }>, 784 + boardsByCategory: Record<string, Array<{ id: string; name: string }>> = {} 785 + ) { 786 + mockFetch.mockResolvedValueOnce( 787 + mockResponse({ 788 + categories: cats.map((c) => ({ 789 + id: c.id, 790 + did: "did:plc:forum", 791 + uri: c.uri, 792 + name: c.name, 793 + description: null, 794 + slug: null, 795 + sortOrder: c.sortOrder ?? 1, 796 + forumId: "1", 797 + createdAt: "2025-01-01T00:00:00.000Z", 798 + indexedAt: "2025-01-01T00:00:00.000Z", 799 + })), 800 + }) 801 + ); 802 + for (const cat of cats) { 803 + const boards = boardsByCategory[cat.id] ?? []; 804 + mockFetch.mockResolvedValueOnce( 805 + mockResponse({ 806 + boards: boards.map((b) => ({ 807 + id: b.id, 808 + did: "did:plc:forum", 809 + uri: `at://did:plc:forum/space.atbb.forum.board/${b.id}`, 810 + name: b.name, 811 + description: null, 812 + slug: null, 813 + sortOrder: 1, 814 + categoryId: cat.id, 815 + categoryUri: cat.uri, 816 + createdAt: "2025-01-01T00:00:00.000Z", 817 + indexedAt: "2025-01-01T00:00:00.000Z", 818 + })), 819 + }) 820 + ); 821 + } 822 + } 823 + 824 + async function loadAdminRoutes() { 825 + const { createAdminRoutes } = await import("../admin.js"); 826 + return createAdminRoutes("http://localhost:3000"); 827 + } 828 + 829 + it("redirects unauthenticated users to /login", async () => { 830 + mockFetch.mockResolvedValueOnce( 831 + mockResponse({ authenticated: false }) 832 + ); 833 + const routes = await loadAdminRoutes(); 834 + const res = await routes.request("/admin/structure"); 835 + expect(res.status).toBe(302); 836 + expect(res.headers.get("location")).toBe("/login"); 837 + }); 838 + 839 + it("returns 403 for authenticated user without manageCategories", async () => { 840 + setupSession(["space.atbb.permission.manageMembers"]); 841 + const routes = await loadAdminRoutes(); 842 + const res = await routes.request("/admin/structure", { 843 + headers: { cookie: "atbb_session=token" }, 844 + }); 845 + expect(res.status).toBe(403); 846 + }); 847 + 848 + it("renders structure page with category and board names", async () => { 849 + setupSession(["space.atbb.permission.manageCategories"]); 850 + setupStructureFetch( 851 + [{ id: "1", name: "General Discussion", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 852 + { "1": [{ id: "10", name: "General Chat" }] } 853 + ); 854 + 855 + const routes = await loadAdminRoutes(); 856 + const res = await routes.request("/admin/structure", { 857 + headers: { cookie: "atbb_session=token" }, 858 + }); 859 + 860 + expect(res.status).toBe(200); 861 + const html = await res.text(); 862 + expect(html).toContain("General Discussion"); 863 + expect(html).toContain("General Chat"); 864 + }); 865 + 866 + it("renders empty state when no categories exist", async () => { 867 + setupSession(["space.atbb.permission.manageCategories"]); 868 + setupStructureFetch([]); 869 + 870 + const routes = await loadAdminRoutes(); 871 + const res = await routes.request("/admin/structure", { 872 + headers: { cookie: "atbb_session=token" }, 873 + }); 874 + 875 + expect(res.status).toBe(200); 876 + const html = await res.text(); 877 + expect(html).toContain("No categories"); 878 + }); 879 + 880 + it("renders the add-category form", async () => { 881 + setupSession(["space.atbb.permission.manageCategories"]); 882 + setupStructureFetch([]); 883 + 884 + const routes = await loadAdminRoutes(); 885 + const res = await routes.request("/admin/structure", { 886 + headers: { cookie: "atbb_session=token" }, 887 + }); 888 + 889 + const html = await res.text(); 890 + expect(html).toContain('action="/admin/structure/categories"'); 891 + }); 892 + 893 + it("renders edit and delete actions for a category", async () => { 894 + setupSession(["space.atbb.permission.manageCategories"]); 895 + setupStructureFetch( 896 + [{ id: "5", name: "Projects", uri: "at://did:plc:forum/space.atbb.forum.category/xyz" }], 897 + ); 898 + 899 + const routes = await loadAdminRoutes(); 900 + const res = await routes.request("/admin/structure", { 901 + headers: { cookie: "atbb_session=token" }, 902 + }); 903 + 904 + const html = await res.text(); 905 + expect(html).toContain('action="/admin/structure/categories/5/edit"'); 906 + expect(html).toContain('action="/admin/structure/categories/5/delete"'); 907 + }); 908 + 909 + it("renders edit and delete actions for a board", async () => { 910 + setupSession(["space.atbb.permission.manageCategories"]); 911 + setupStructureFetch( 912 + [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 913 + { "1": [{ id: "20", name: "Showcase" }] } 914 + ); 915 + 916 + const routes = await loadAdminRoutes(); 917 + const res = await routes.request("/admin/structure", { 918 + headers: { cookie: "atbb_session=token" }, 919 + }); 920 + 921 + const html = await res.text(); 922 + expect(html).toContain("Showcase"); 923 + expect(html).toContain('action="/admin/structure/boards/20/edit"'); 924 + expect(html).toContain('action="/admin/structure/boards/20/delete"'); 925 + }); 926 + 927 + it("renders add-board form with categoryUri hidden input", async () => { 928 + setupSession(["space.atbb.permission.manageCategories"]); 929 + setupStructureFetch( 930 + [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 931 + ); 932 + 933 + const routes = await loadAdminRoutes(); 934 + const res = await routes.request("/admin/structure", { 935 + headers: { cookie: "atbb_session=token" }, 936 + }); 937 + 938 + const html = await res.text(); 939 + expect(html).toContain('name="categoryUri"'); 940 + expect(html).toContain('value="at://did:plc:forum/space.atbb.forum.category/abc"'); 941 + expect(html).toContain('action="/admin/structure/boards"'); 942 + }); 943 + 944 + it("renders error banner when ?error= query param is present", async () => { 945 + setupSession(["space.atbb.permission.manageCategories"]); 946 + setupStructureFetch([]); 947 + 948 + const routes = await loadAdminRoutes(); 949 + const res = await routes.request( 950 + `/admin/structure?error=${encodeURIComponent("Cannot delete category with boards. Remove all boards first.")}`, 951 + { headers: { cookie: "atbb_session=token" } } 952 + ); 953 + 954 + const html = await res.text(); 955 + expect(html).toContain("Cannot delete category with boards"); 956 + }); 957 + 958 + it("returns 503 on AppView network error fetching categories", async () => { 959 + setupSession(["space.atbb.permission.manageCategories"]); 960 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 961 + 962 + const routes = await loadAdminRoutes(); 963 + const res = await routes.request("/admin/structure", { 964 + headers: { cookie: "atbb_session=token" }, 965 + }); 966 + 967 + expect(res.status).toBe(503); 968 + const html = await res.text(); 969 + expect(html).toContain("error-display"); 970 + }); 971 + 972 + it("returns 500 on AppView server error fetching categories", async () => { 973 + setupSession(["space.atbb.permission.manageCategories"]); 974 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 975 + 976 + const routes = await loadAdminRoutes(); 977 + const res = await routes.request("/admin/structure", { 978 + headers: { cookie: "atbb_session=token" }, 979 + }); 980 + 981 + expect(res.status).toBe(500); 982 + const html = await res.text(); 983 + expect(html).toContain("error-display"); 984 + }); 985 + 986 + it("redirects to /login when AppView categories returns 401", async () => { 987 + setupSession(["space.atbb.permission.manageCategories"]); 988 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 989 + 990 + const routes = await loadAdminRoutes(); 991 + const res = await routes.request("/admin/structure", { 992 + headers: { cookie: "atbb_session=token" }, 993 + }); 994 + 995 + expect(res.status).toBe(302); 996 + expect(res.headers.get("location")).toBe("/login"); 997 + }); 998 + }); 999 + 1000 + describe("createAdminRoutes — POST /admin/structure/categories", () => { 1001 + beforeEach(() => { 1002 + vi.stubGlobal("fetch", mockFetch); 1003 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1004 + vi.resetModules(); 1005 + }); 1006 + 1007 + afterEach(() => { 1008 + vi.unstubAllGlobals(); 1009 + vi.unstubAllEnvs(); 1010 + mockFetch.mockReset(); 1011 + }); 1012 + 1013 + function mockResponse(body: unknown, ok = true, status = 200) { 1014 + return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1015 + } 1016 + 1017 + function setupSession(permissions: string[]) { 1018 + mockFetch.mockResolvedValueOnce( 1019 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1020 + ); 1021 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1022 + } 1023 + 1024 + async function loadAdminRoutes() { 1025 + const { createAdminRoutes } = await import("../admin.js"); 1026 + return createAdminRoutes("http://localhost:3000"); 1027 + } 1028 + 1029 + function postForm(body: Record<string, string>) { 1030 + const params = new URLSearchParams(body); 1031 + return { 1032 + method: "POST", 1033 + headers: { 1034 + cookie: "atbb_session=token", 1035 + "content-type": "application/x-www-form-urlencoded", 1036 + }, 1037 + body: params.toString(), 1038 + }; 1039 + } 1040 + 1041 + it("redirects to /login when unauthenticated", async () => { 1042 + mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1043 + const routes = await loadAdminRoutes(); 1044 + const res = await routes.request("/admin/structure/categories", postForm({ name: "General" })); 1045 + expect(res.status).toBe(302); 1046 + expect(res.headers.get("location")).toBe("/login"); 1047 + }); 1048 + 1049 + it("returns 403 without manageCategories permission", async () => { 1050 + setupSession(["space.atbb.permission.manageMembers"]); 1051 + const routes = await loadAdminRoutes(); 1052 + const res = await routes.request("/admin/structure/categories", postForm({ name: "General" })); 1053 + expect(res.status).toBe(403); 1054 + }); 1055 + 1056 + it("redirects to /admin/structure on success", async () => { 1057 + setupSession(["space.atbb.permission.manageCategories"]); 1058 + mockFetch.mockResolvedValueOnce( 1059 + mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.category/abc", cid: "bafyrei..." }, true, 201) 1060 + ); 1061 + 1062 + const routes = await loadAdminRoutes(); 1063 + const res = await routes.request( 1064 + "/admin/structure/categories", 1065 + postForm({ name: "General", description: "Talk about anything", sortOrder: "1" }) 1066 + ); 1067 + 1068 + expect(res.status).toBe(302); 1069 + expect(res.headers.get("location")).toBe("/admin/structure"); 1070 + }); 1071 + 1072 + it("redirects with ?error= when name is missing", async () => { 1073 + setupSession(["space.atbb.permission.manageCategories"]); 1074 + 1075 + const routes = await loadAdminRoutes(); 1076 + const res = await routes.request( 1077 + "/admin/structure/categories", 1078 + postForm({ name: "" }) 1079 + ); 1080 + 1081 + expect(res.status).toBe(302); 1082 + const location = res.headers.get("location") ?? ""; 1083 + expect(location).toContain("/admin/structure"); 1084 + expect(location).toContain("error="); 1085 + }); 1086 + 1087 + it("redirects with ?error= on AppView error", async () => { 1088 + setupSession(["space.atbb.permission.manageCategories"]); 1089 + mockFetch.mockResolvedValueOnce( 1090 + mockResponse({ error: "Unexpected error" }, false, 500) 1091 + ); 1092 + 1093 + const routes = await loadAdminRoutes(); 1094 + const res = await routes.request( 1095 + "/admin/structure/categories", 1096 + postForm({ name: "General" }) 1097 + ); 1098 + 1099 + expect(res.status).toBe(302); 1100 + const location = res.headers.get("location") ?? ""; 1101 + expect(location).toContain("/admin/structure"); 1102 + expect(location).toContain("error="); 1103 + }); 1104 + 1105 + it("redirects with ?error= on network error", async () => { 1106 + setupSession(["space.atbb.permission.manageCategories"]); 1107 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1108 + 1109 + const routes = await loadAdminRoutes(); 1110 + const res = await routes.request( 1111 + "/admin/structure/categories", 1112 + postForm({ name: "General" }) 1113 + ); 1114 + 1115 + expect(res.status).toBe(302); 1116 + const location = res.headers.get("location") ?? ""; 1117 + expect(location).toContain("/admin/structure"); 1118 + expect(location).toContain("error="); 1119 + }); 1120 + 1121 + it("redirects with ?error= for negative sort order", async () => { 1122 + setupSession(["space.atbb.permission.manageCategories"]); 1123 + 1124 + const routes = await loadAdminRoutes(); 1125 + const res = await routes.request( 1126 + "/admin/structure/categories", 1127 + postForm({ name: "General", sortOrder: "-1" }) 1128 + ); 1129 + 1130 + expect(res.status).toBe(302); 1131 + const location = res.headers.get("location") ?? ""; 1132 + expect(location).toContain("error="); 1133 + }); 1134 + }); 1135 + 1136 + describe("createAdminRoutes — POST /admin/structure/categories/:id/edit", () => { 1137 + beforeEach(() => { 1138 + vi.stubGlobal("fetch", mockFetch); 1139 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1140 + vi.resetModules(); 1141 + }); 1142 + 1143 + afterEach(() => { 1144 + vi.unstubAllGlobals(); 1145 + vi.unstubAllEnvs(); 1146 + mockFetch.mockReset(); 1147 + }); 1148 + 1149 + function mockResponse(body: unknown, ok = true, status = 200) { 1150 + return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1151 + } 1152 + 1153 + function setupSession(permissions: string[]) { 1154 + mockFetch.mockResolvedValueOnce( 1155 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1156 + ); 1157 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1158 + } 1159 + 1160 + async function loadAdminRoutes() { 1161 + const { createAdminRoutes } = await import("../admin.js"); 1162 + return createAdminRoutes("http://localhost:3000"); 1163 + } 1164 + 1165 + function postForm(body: Record<string, string>) { 1166 + const params = new URLSearchParams(body); 1167 + return { 1168 + method: "POST", 1169 + headers: { 1170 + cookie: "atbb_session=token", 1171 + "content-type": "application/x-www-form-urlencoded", 1172 + }, 1173 + body: params.toString(), 1174 + }; 1175 + } 1176 + 1177 + it("redirects to /login when unauthenticated", async () => { 1178 + mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1179 + const routes = await loadAdminRoutes(); 1180 + const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1181 + expect(res.status).toBe(302); 1182 + expect(res.headers.get("location")).toBe("/login"); 1183 + }); 1184 + 1185 + it("returns 403 without manageCategories", async () => { 1186 + setupSession(["space.atbb.permission.manageMembers"]); 1187 + const routes = await loadAdminRoutes(); 1188 + const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1189 + expect(res.status).toBe(403); 1190 + }); 1191 + 1192 + it("redirects to /admin/structure on success", async () => { 1193 + setupSession(["space.atbb.permission.manageCategories"]); 1194 + mockFetch.mockResolvedValueOnce( 1195 + mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200) 1196 + ); 1197 + 1198 + const routes = await loadAdminRoutes(); 1199 + const res = await routes.request( 1200 + "/admin/structure/categories/5/edit", 1201 + postForm({ name: "Updated Name", description: "", sortOrder: "2" }) 1202 + ); 1203 + 1204 + expect(res.status).toBe(302); 1205 + expect(res.headers.get("location")).toBe("/admin/structure"); 1206 + }); 1207 + 1208 + it("redirects with ?error= when name is missing", async () => { 1209 + setupSession(["space.atbb.permission.manageCategories"]); 1210 + const routes = await loadAdminRoutes(); 1211 + const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "" })); 1212 + expect(res.status).toBe(302); 1213 + const location = res.headers.get("location") ?? ""; 1214 + expect(location).toContain("error="); 1215 + }); 1216 + 1217 + it("redirects with ?error= on AppView error", async () => { 1218 + setupSession(["space.atbb.permission.manageCategories"]); 1219 + mockFetch.mockResolvedValueOnce(mockResponse({ error: "Not found" }, false, 404)); 1220 + const routes = await loadAdminRoutes(); 1221 + const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1222 + expect(res.status).toBe(302); 1223 + const location = res.headers.get("location") ?? ""; 1224 + expect(location).toContain("error="); 1225 + }); 1226 + 1227 + it("redirects with ?error= on network error", async () => { 1228 + setupSession(["space.atbb.permission.manageCategories"]); 1229 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1230 + const routes = await loadAdminRoutes(); 1231 + const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1232 + expect(res.status).toBe(302); 1233 + const location = res.headers.get("location") ?? ""; 1234 + expect(location).toContain("error="); 1235 + }); 1236 + 1237 + it("redirects with ?error= for negative sort order", async () => { 1238 + setupSession(["space.atbb.permission.manageCategories"]); 1239 + const routes = await loadAdminRoutes(); 1240 + const res = await routes.request( 1241 + "/admin/structure/categories/5/edit", 1242 + postForm({ name: "Updated", sortOrder: "-5" }) 1243 + ); 1244 + expect(res.status).toBe(302); 1245 + const location = res.headers.get("location") ?? ""; 1246 + expect(location).toContain("error="); 1247 + }); 1248 + }); 1249 + 1250 + describe("createAdminRoutes — POST /admin/structure/categories/:id/delete", () => { 1251 + beforeEach(() => { 1252 + vi.stubGlobal("fetch", mockFetch); 1253 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1254 + vi.resetModules(); 1255 + }); 1256 + 1257 + afterEach(() => { 1258 + vi.unstubAllGlobals(); 1259 + vi.unstubAllEnvs(); 1260 + mockFetch.mockReset(); 1261 + }); 1262 + 1263 + function mockResponse(body: unknown, ok = true, status = 200) { 1264 + return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1265 + } 1266 + 1267 + function setupSession(permissions: string[]) { 1268 + mockFetch.mockResolvedValueOnce( 1269 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1270 + ); 1271 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1272 + } 1273 + 1274 + async function loadAdminRoutes() { 1275 + const { createAdminRoutes } = await import("../admin.js"); 1276 + return createAdminRoutes("http://localhost:3000"); 1277 + } 1278 + 1279 + function postForm(body: Record<string, string> = {}) { 1280 + const params = new URLSearchParams(body); 1281 + return { 1282 + method: "POST", 1283 + headers: { 1284 + cookie: "atbb_session=token", 1285 + "content-type": "application/x-www-form-urlencoded", 1286 + }, 1287 + body: params.toString(), 1288 + }; 1289 + } 1290 + 1291 + it("redirects to /login when unauthenticated", async () => { 1292 + mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1293 + const routes = await loadAdminRoutes(); 1294 + const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1295 + expect(res.status).toBe(302); 1296 + expect(res.headers.get("location")).toBe("/login"); 1297 + }); 1298 + 1299 + it("returns 403 without manageCategories", async () => { 1300 + setupSession(["space.atbb.permission.manageMembers"]); 1301 + const routes = await loadAdminRoutes(); 1302 + const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1303 + expect(res.status).toBe(403); 1304 + }); 1305 + 1306 + it("redirects to /admin/structure on success", async () => { 1307 + setupSession(["space.atbb.permission.manageCategories"]); 1308 + mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200)); 1309 + 1310 + const routes = await loadAdminRoutes(); 1311 + const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1312 + 1313 + expect(res.status).toBe(302); 1314 + expect(res.headers.get("location")).toBe("/admin/structure"); 1315 + }); 1316 + 1317 + it("redirects with ?error= on AppView error (e.g. 409 has boards)", async () => { 1318 + setupSession(["space.atbb.permission.manageCategories"]); 1319 + mockFetch.mockResolvedValueOnce( 1320 + mockResponse({ error: "Cannot delete category with boards. Remove all boards first." }, false, 409) 1321 + ); 1322 + 1323 + const routes = await loadAdminRoutes(); 1324 + const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1325 + 1326 + expect(res.status).toBe(302); 1327 + const location = res.headers.get("location") ?? ""; 1328 + expect(location).toContain("/admin/structure"); 1329 + expect(location).toContain("error="); 1330 + expect(decodeURIComponent(location)).toContain("Cannot delete category with boards"); 1331 + }); 1332 + 1333 + it("redirects with ?error= on network error", async () => { 1334 + setupSession(["space.atbb.permission.manageCategories"]); 1335 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1336 + 1337 + const routes = await loadAdminRoutes(); 1338 + const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1339 + 1340 + expect(res.status).toBe(302); 1341 + const location = res.headers.get("location") ?? ""; 1342 + expect(location).toContain("error="); 1343 + }); 1344 + }); 1345 + 1346 + describe("createAdminRoutes — POST /admin/structure/boards", () => { 1347 + beforeEach(() => { 1348 + vi.stubGlobal("fetch", mockFetch); 1349 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1350 + vi.resetModules(); 1351 + }); 1352 + 1353 + afterEach(() => { 1354 + vi.unstubAllGlobals(); 1355 + vi.unstubAllEnvs(); 1356 + mockFetch.mockReset(); 1357 + }); 1358 + 1359 + function mockResponse(body: unknown, ok = true, status = 200) { 1360 + return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1361 + } 1362 + 1363 + function setupSession(permissions: string[]) { 1364 + mockFetch.mockResolvedValueOnce( 1365 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1366 + ); 1367 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1368 + } 1369 + 1370 + async function loadAdminRoutes() { 1371 + const { createAdminRoutes } = await import("../admin.js"); 1372 + return createAdminRoutes("http://localhost:3000"); 1373 + } 1374 + 1375 + function postForm(body: Record<string, string>) { 1376 + const params = new URLSearchParams(body); 1377 + return { 1378 + method: "POST", 1379 + headers: { 1380 + cookie: "atbb_session=token", 1381 + "content-type": "application/x-www-form-urlencoded", 1382 + }, 1383 + body: params.toString(), 1384 + }; 1385 + } 1386 + 1387 + it("redirects to /login when unauthenticated", async () => { 1388 + mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1389 + const routes = await loadAdminRoutes(); 1390 + const res = await routes.request( 1391 + "/admin/structure/boards", 1392 + postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1393 + ); 1394 + expect(res.status).toBe(302); 1395 + expect(res.headers.get("location")).toBe("/login"); 1396 + }); 1397 + 1398 + it("returns 403 without manageCategories permission", async () => { 1399 + setupSession(["space.atbb.permission.manageMembers"]); 1400 + const routes = await loadAdminRoutes(); 1401 + const res = await routes.request( 1402 + "/admin/structure/boards", 1403 + postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1404 + ); 1405 + expect(res.status).toBe(403); 1406 + }); 1407 + 1408 + it("redirects to /admin/structure on success", async () => { 1409 + setupSession(["space.atbb.permission.manageCategories"]); 1410 + mockFetch.mockResolvedValueOnce( 1411 + mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.board/xyz", cid: "bafyrei..." }, true, 201) 1412 + ); 1413 + 1414 + const routes = await loadAdminRoutes(); 1415 + const res = await routes.request( 1416 + "/admin/structure/boards", 1417 + postForm({ 1418 + name: "General Chat", 1419 + description: "Chat about anything", 1420 + sortOrder: "1", 1421 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1422 + }) 1423 + ); 1424 + 1425 + expect(res.status).toBe(302); 1426 + expect(res.headers.get("location")).toBe("/admin/structure"); 1427 + }); 1428 + 1429 + it("redirects with ?error= when name is missing", async () => { 1430 + setupSession(["space.atbb.permission.manageCategories"]); 1431 + const routes = await loadAdminRoutes(); 1432 + const res = await routes.request( 1433 + "/admin/structure/boards", 1434 + postForm({ name: "", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1435 + ); 1436 + expect(res.status).toBe(302); 1437 + const location = res.headers.get("location") ?? ""; 1438 + expect(location).toContain("/admin/structure"); 1439 + expect(location).toContain("error="); 1440 + }); 1441 + 1442 + it("redirects with ?error= when categoryUri is missing", async () => { 1443 + setupSession(["space.atbb.permission.manageCategories"]); 1444 + const routes = await loadAdminRoutes(); 1445 + const res = await routes.request( 1446 + "/admin/structure/boards", 1447 + postForm({ name: "General Chat", categoryUri: "" }) 1448 + ); 1449 + expect(res.status).toBe(302); 1450 + const location = res.headers.get("location") ?? ""; 1451 + expect(location).toContain("error="); 1452 + }); 1453 + 1454 + it("redirects with ?error= on AppView error", async () => { 1455 + setupSession(["space.atbb.permission.manageCategories"]); 1456 + mockFetch.mockResolvedValueOnce( 1457 + mockResponse({ error: "Category not found" }, false, 404) 1458 + ); 1459 + 1460 + const routes = await loadAdminRoutes(); 1461 + const res = await routes.request( 1462 + "/admin/structure/boards", 1463 + postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1464 + ); 1465 + 1466 + expect(res.status).toBe(302); 1467 + const location = res.headers.get("location") ?? ""; 1468 + expect(location).toContain("error="); 1469 + }); 1470 + 1471 + it("redirects with ?error= on network error", async () => { 1472 + setupSession(["space.atbb.permission.manageCategories"]); 1473 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1474 + 1475 + const routes = await loadAdminRoutes(); 1476 + const res = await routes.request( 1477 + "/admin/structure/boards", 1478 + postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1479 + ); 1480 + 1481 + expect(res.status).toBe(302); 1482 + const location = res.headers.get("location") ?? ""; 1483 + expect(location).toContain("error="); 1484 + }); 1485 + 1486 + it("redirects with ?error= for negative sort order", async () => { 1487 + setupSession(["space.atbb.permission.manageCategories"]); 1488 + const routes = await loadAdminRoutes(); 1489 + const res = await routes.request( 1490 + "/admin/structure/boards", 1491 + postForm({ 1492 + name: "General Chat", 1493 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1494 + sortOrder: "-2", 1495 + }) 1496 + ); 1497 + expect(res.status).toBe(302); 1498 + const location = res.headers.get("location") ?? ""; 1499 + expect(location).toContain("error="); 1500 + }); 1501 + }); 1502 + 1503 + describe("createAdminRoutes — POST /admin/structure/boards/:id/edit", () => { 1504 + beforeEach(() => { 1505 + vi.stubGlobal("fetch", mockFetch); 1506 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1507 + vi.resetModules(); 1508 + }); 1509 + 1510 + afterEach(() => { 1511 + vi.unstubAllGlobals(); 1512 + vi.unstubAllEnvs(); 1513 + mockFetch.mockReset(); 1514 + }); 1515 + 1516 + function mockResponse(body: unknown, ok = true, status = 200) { 1517 + return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1518 + } 1519 + 1520 + function setupSession(permissions: string[]) { 1521 + mockFetch.mockResolvedValueOnce( 1522 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1523 + ); 1524 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1525 + } 1526 + 1527 + async function loadAdminRoutes() { 1528 + const { createAdminRoutes } = await import("../admin.js"); 1529 + return createAdminRoutes("http://localhost:3000"); 1530 + } 1531 + 1532 + function postForm(body: Record<string, string>) { 1533 + const params = new URLSearchParams(body); 1534 + return { 1535 + method: "POST", 1536 + headers: { 1537 + cookie: "atbb_session=token", 1538 + "content-type": "application/x-www-form-urlencoded", 1539 + }, 1540 + body: params.toString(), 1541 + }; 1542 + } 1543 + 1544 + it("redirects to /login when unauthenticated", async () => { 1545 + mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1546 + const routes = await loadAdminRoutes(); 1547 + const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); 1548 + expect(res.status).toBe(302); 1549 + expect(res.headers.get("location")).toBe("/login"); 1550 + }); 1551 + 1552 + it("returns 403 without manageCategories", async () => { 1553 + setupSession(["space.atbb.permission.manageMembers"]); 1554 + const routes = await loadAdminRoutes(); 1555 + const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); 1556 + expect(res.status).toBe(403); 1557 + }); 1558 + 1559 + it("redirects to /admin/structure on success", async () => { 1560 + setupSession(["space.atbb.permission.manageCategories"]); 1561 + mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200)); 1562 + 1563 + const routes = await loadAdminRoutes(); 1564 + const res = await routes.request( 1565 + "/admin/structure/boards/10/edit", 1566 + postForm({ name: "Updated Board", description: "", sortOrder: "3" }) 1567 + ); 1568 + 1569 + expect(res.status).toBe(302); 1570 + expect(res.headers.get("location")).toBe("/admin/structure"); 1571 + }); 1572 + 1573 + it("redirects with ?error= when name is missing", async () => { 1574 + setupSession(["space.atbb.permission.manageCategories"]); 1575 + const routes = await loadAdminRoutes(); 1576 + const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "" })); 1577 + expect(res.status).toBe(302); 1578 + const location = res.headers.get("location") ?? ""; 1579 + expect(location).toContain("error="); 1580 + }); 1581 + 1582 + it("redirects with ?error= on AppView error", async () => { 1583 + setupSession(["space.atbb.permission.manageCategories"]); 1584 + mockFetch.mockResolvedValueOnce(mockResponse({ error: "Board not found" }, false, 404)); 1585 + const routes = await loadAdminRoutes(); 1586 + const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); 1587 + expect(res.status).toBe(302); 1588 + const location = res.headers.get("location") ?? ""; 1589 + expect(location).toContain("error="); 1590 + }); 1591 + 1592 + it("redirects with ?error= on network error", async () => { 1593 + setupSession(["space.atbb.permission.manageCategories"]); 1594 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1595 + const routes = await loadAdminRoutes(); 1596 + const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); 1597 + expect(res.status).toBe(302); 1598 + const location = res.headers.get("location") ?? ""; 1599 + expect(location).toContain("error="); 1600 + }); 1601 + 1602 + it("redirects with ?error= for negative sort order", async () => { 1603 + setupSession(["space.atbb.permission.manageCategories"]); 1604 + const routes = await loadAdminRoutes(); 1605 + const res = await routes.request( 1606 + "/admin/structure/boards/10/edit", 1607 + postForm({ name: "Updated Board", sortOrder: "-3" }) 1608 + ); 1609 + expect(res.status).toBe(302); 1610 + const location = res.headers.get("location") ?? ""; 1611 + expect(location).toContain("error="); 1612 + }); 1613 + }); 1614 + 1615 + describe("createAdminRoutes — POST /admin/structure/boards/:id/delete", () => { 1616 + beforeEach(() => { 1617 + vi.stubGlobal("fetch", mockFetch); 1618 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1619 + vi.resetModules(); 1620 + }); 1621 + 1622 + afterEach(() => { 1623 + vi.unstubAllGlobals(); 1624 + vi.unstubAllEnvs(); 1625 + mockFetch.mockReset(); 1626 + }); 1627 + 1628 + function mockResponse(body: unknown, ok = true, status = 200) { 1629 + return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1630 + } 1631 + 1632 + function setupSession(permissions: string[]) { 1633 + mockFetch.mockResolvedValueOnce( 1634 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1635 + ); 1636 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1637 + } 1638 + 1639 + async function loadAdminRoutes() { 1640 + const { createAdminRoutes } = await import("../admin.js"); 1641 + return createAdminRoutes("http://localhost:3000"); 1642 + } 1643 + 1644 + function postForm(body: Record<string, string> = {}) { 1645 + const params = new URLSearchParams(body); 1646 + return { 1647 + method: "POST", 1648 + headers: { 1649 + cookie: "atbb_session=token", 1650 + "content-type": "application/x-www-form-urlencoded", 1651 + }, 1652 + body: params.toString(), 1653 + }; 1654 + } 1655 + 1656 + it("redirects to /login when unauthenticated", async () => { 1657 + mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1658 + const routes = await loadAdminRoutes(); 1659 + const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1660 + expect(res.status).toBe(302); 1661 + expect(res.headers.get("location")).toBe("/login"); 1662 + }); 1663 + 1664 + it("returns 403 without manageCategories", async () => { 1665 + setupSession(["space.atbb.permission.manageMembers"]); 1666 + const routes = await loadAdminRoutes(); 1667 + const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1668 + expect(res.status).toBe(403); 1669 + }); 1670 + 1671 + it("redirects to /admin/structure on success", async () => { 1672 + setupSession(["space.atbb.permission.manageCategories"]); 1673 + mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200)); 1674 + 1675 + const routes = await loadAdminRoutes(); 1676 + const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1677 + 1678 + expect(res.status).toBe(302); 1679 + expect(res.headers.get("location")).toBe("/admin/structure"); 1680 + }); 1681 + 1682 + it("redirects with ?error= on AppView error (e.g. 409 has posts)", async () => { 1683 + setupSession(["space.atbb.permission.manageCategories"]); 1684 + mockFetch.mockResolvedValueOnce( 1685 + mockResponse({ error: "Cannot delete board with posts. Remove all posts first." }, false, 409) 1686 + ); 1687 + 1688 + const routes = await loadAdminRoutes(); 1689 + const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1690 + 1691 + expect(res.status).toBe(302); 1692 + const location = res.headers.get("location") ?? ""; 1693 + expect(decodeURIComponent(location)).toContain("Cannot delete board with posts"); 1694 + }); 1695 + 1696 + it("redirects with ?error= on network error", async () => { 1697 + setupSession(["space.atbb.permission.manageCategories"]); 1698 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1699 + 1700 + const routes = await loadAdminRoutes(); 1701 + const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1702 + 1703 + expect(res.status).toBe(302); 1704 + const location = res.headers.get("location") ?? ""; 1705 + expect(location).toContain("error="); 1706 + }); 1707 + });
+770
apps/web/src/routes/admin.tsx
··· 29 29 priority: number; 30 30 } 31 31 32 + interface CategoryEntry { 33 + id: string; 34 + did: string; 35 + uri: string; 36 + name: string; 37 + description: string | null; 38 + sortOrder: number | null; 39 + } 40 + 41 + interface BoardEntry { 42 + id: string; 43 + name: string; 44 + description: string | null; 45 + sortOrder: number | null; 46 + categoryUri: string; 47 + uri: string; 48 + } 49 + 32 50 // ─── Helpers ─────────────────────────────────────────────────────────────── 33 51 34 52 function formatJoinedDate(isoString: string | null): string { ··· 103 121 ); 104 122 } 105 123 124 + // ─── Private Helpers ──────────────────────────────────────────────────────── 125 + 126 + /** 127 + * Extracts the error message from an AppView error response. 128 + * Falls back to the provided default if JSON parsing fails. 129 + */ 130 + async function extractAppviewError(res: Response, fallback: string): Promise<string> { 131 + try { 132 + const data = (await res.json()) as { error?: string }; 133 + return data.error ?? fallback; 134 + } catch { 135 + return fallback; 136 + } 137 + } 138 + 139 + /** 140 + * Parses a sort order value from a form field string. 141 + * Returns 0 for empty/missing values, null for invalid values (negative or non-integer). 142 + */ 143 + function parseSortOrder(value: unknown): number | null { 144 + if (typeof value !== "string" || value.trim() === "") return 0; 145 + const n = Number(value); 146 + return Number.isInteger(n) && n >= 0 ? n : null; 147 + } 148 + 106 149 // ─── Routes ──────────────────────────────────────────────────────────────── 107 150 108 151 export function createAdminRoutes(appviewUrl: string) { 109 152 const app = new Hono(); 110 153 154 + // ─── Structure Page Components ────────────────────────────────────────── 155 + 156 + function StructureBoardRow({ board }: { board: BoardEntry }) { 157 + const dialogId = `confirm-delete-board-${board.id}`; 158 + return ( 159 + <div class="structure-board"> 160 + <div class="structure-board__header"> 161 + <span class="structure-board__name">{board.name}</span> 162 + <span class="structure-board__meta">sortOrder: {board.sortOrder ?? 0}</span> 163 + <div class="structure-board__actions"> 164 + <button 165 + type="button" 166 + class="btn btn-secondary btn-sm" 167 + onclick={`document.getElementById('edit-board-${board.id}').open=!document.getElementById('edit-board-${board.id}').open`} 168 + > 169 + Edit 170 + </button> 171 + <button 172 + type="button" 173 + class="btn btn-danger btn-sm" 174 + onclick={`document.getElementById('${dialogId}').showModal()`} 175 + > 176 + Delete 177 + </button> 178 + </div> 179 + </div> 180 + <details id={`edit-board-${board.id}`} class="structure-edit-form"> 181 + <summary class="sr-only">Edit {board.name}</summary> 182 + <form method="post" action={`/admin/structure/boards/${board.id}/edit`} class="structure-edit-form__body"> 183 + <div class="form-group"> 184 + <label for={`edit-board-name-${board.id}`}>Name</label> 185 + <input id={`edit-board-name-${board.id}`} type="text" name="name" value={board.name} required /> 186 + </div> 187 + <div class="form-group"> 188 + <label for={`edit-board-desc-${board.id}`}>Description</label> 189 + <textarea id={`edit-board-desc-${board.id}`} name="description">{board.description ?? ""}</textarea> 190 + </div> 191 + <div class="form-group"> 192 + <label for={`edit-board-sort-${board.id}`}>Sort Order</label> 193 + <input id={`edit-board-sort-${board.id}`} type="number" name="sortOrder" min="0" value={String(board.sortOrder ?? 0)} /> 194 + </div> 195 + <button type="submit" class="btn btn-primary">Save Changes</button> 196 + </form> 197 + </details> 198 + <dialog id={dialogId} class="structure-confirm-dialog"> 199 + <p>Delete board &quot;{board.name}&quot;? This cannot be undone.</p> 200 + <form method="post" action={`/admin/structure/boards/${board.id}/delete`} class="dialog-actions"> 201 + <button type="submit" class="btn btn-danger">Delete</button> 202 + <button 203 + type="button" 204 + class="btn btn-secondary" 205 + onclick={`document.getElementById('${dialogId}').close()`} 206 + > 207 + Cancel 208 + </button> 209 + </form> 210 + </dialog> 211 + </div> 212 + ); 213 + } 214 + 215 + function StructureCategorySection({ 216 + category, 217 + boards, 218 + }: { 219 + category: CategoryEntry; 220 + boards: BoardEntry[]; 221 + }) { 222 + const dialogId = `confirm-delete-category-${category.id}`; 223 + return ( 224 + <div class="structure-category"> 225 + <div class="structure-category__header"> 226 + <span class="structure-category__name">{category.name}</span> 227 + <span class="structure-category__meta">sortOrder: {category.sortOrder ?? 0}</span> 228 + <div class="structure-category__actions"> 229 + <button 230 + type="button" 231 + class="btn btn-secondary btn-sm" 232 + onclick={`document.getElementById('edit-category-${category.id}').open=!document.getElementById('edit-category-${category.id}').open`} 233 + > 234 + Edit 235 + </button> 236 + <button 237 + type="button" 238 + class="btn btn-danger btn-sm" 239 + onclick={`document.getElementById('${dialogId}').showModal()`} 240 + > 241 + Delete 242 + </button> 243 + </div> 244 + </div> 245 + 246 + <details id={`edit-category-${category.id}`} class="structure-edit-form"> 247 + <summary class="sr-only">Edit {category.name}</summary> 248 + <form method="post" action={`/admin/structure/categories/${category.id}/edit`} class="structure-edit-form__body"> 249 + <div class="form-group"> 250 + <label for={`edit-cat-name-${category.id}`}>Name</label> 251 + <input id={`edit-cat-name-${category.id}`} type="text" name="name" value={category.name} required /> 252 + </div> 253 + <div class="form-group"> 254 + <label for={`edit-cat-desc-${category.id}`}>Description</label> 255 + <textarea id={`edit-cat-desc-${category.id}`} name="description">{category.description ?? ""}</textarea> 256 + </div> 257 + <div class="form-group"> 258 + <label for={`edit-cat-sort-${category.id}`}>Sort Order</label> 259 + <input id={`edit-cat-sort-${category.id}`} type="number" name="sortOrder" min="0" value={String(category.sortOrder ?? 0)} /> 260 + </div> 261 + <button type="submit" class="btn btn-primary">Save Changes</button> 262 + </form> 263 + </details> 264 + 265 + <dialog id={dialogId} class="structure-confirm-dialog"> 266 + <p>Delete category &quot;{category.name}&quot;? All boards must be removed first.</p> 267 + <form method="post" action={`/admin/structure/categories/${category.id}/delete`} class="dialog-actions"> 268 + <button type="submit" class="btn btn-danger">Delete</button> 269 + <button 270 + type="button" 271 + class="btn btn-secondary" 272 + onclick={`document.getElementById('${dialogId}').close()`} 273 + > 274 + Cancel 275 + </button> 276 + </form> 277 + </dialog> 278 + 279 + <div class="structure-boards"> 280 + {boards.map((board) => ( 281 + <StructureBoardRow board={board} /> 282 + ))} 283 + <details class="structure-add-board"> 284 + <summary class="structure-add-board__trigger">+ Add Board</summary> 285 + <form method="post" action="/admin/structure/boards" class="structure-edit-form__body"> 286 + <input type="hidden" name="categoryUri" value={category.uri} /> 287 + <div class="form-group"> 288 + <label for={`new-board-name-${category.id}`}>Name</label> 289 + <input id={`new-board-name-${category.id}`} type="text" name="name" required /> 290 + </div> 291 + <div class="form-group"> 292 + <label for={`new-board-desc-${category.id}`}>Description</label> 293 + <textarea id={`new-board-desc-${category.id}`} name="description"></textarea> 294 + </div> 295 + <div class="form-group"> 296 + <label for={`new-board-sort-${category.id}`}>Sort Order</label> 297 + <input id={`new-board-sort-${category.id}`} type="number" name="sortOrder" min="0" value="0" /> 298 + </div> 299 + <button type="submit" class="btn btn-primary">Add Board</button> 300 + </form> 301 + </details> 302 + </div> 303 + </div> 304 + ); 305 + } 306 + 111 307 // ── GET /admin ──────────────────────────────────────────────────────────── 112 308 113 309 app.get("/admin", async (c) => { ··· 474 670 errorMsg={errorMsg} 475 671 /> 476 672 ); 673 + }); 674 + 675 + // ── GET /admin/structure ───────────────────────────────────────────────── 676 + 677 + app.get("/admin/structure", async (c) => { 678 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 679 + 680 + if (!auth.authenticated) { 681 + return c.redirect("/login"); 682 + } 683 + 684 + if (!canManageCategories(auth)) { 685 + return c.html( 686 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 687 + <PageHeader title="Forum Structure" /> 688 + <p>You don&apos;t have permission to manage forum structure.</p> 689 + </BaseLayout>, 690 + 403 691 + ); 692 + } 693 + 694 + const cookie = c.req.header("cookie") ?? ""; 695 + const errorMsg = c.req.query("error") ?? null; 696 + 697 + let categoriesRes: Response; 698 + try { 699 + categoriesRes = await fetch(`${appviewUrl}/api/categories`, { 700 + headers: { Cookie: cookie }, 701 + }); 702 + } catch (error) { 703 + if (isProgrammingError(error)) throw error; 704 + logger.error("Network error fetching categories for structure page", { 705 + operation: "GET /admin/structure", 706 + error: error instanceof Error ? error.message : String(error), 707 + }); 708 + return c.html( 709 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 710 + <PageHeader title="Forum Structure" /> 711 + <ErrorDisplay 712 + message="Unable to load forum structure" 713 + detail="The forum is temporarily unavailable. Please try again." 714 + /> 715 + </BaseLayout>, 716 + 503 717 + ); 718 + } 719 + 720 + if (!categoriesRes.ok) { 721 + if (categoriesRes.status === 401) { 722 + return c.redirect("/login"); 723 + } 724 + logger.error("AppView returned error for categories list", { 725 + operation: "GET /admin/structure", 726 + status: categoriesRes.status, 727 + }); 728 + return c.html( 729 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 730 + <PageHeader title="Forum Structure" /> 731 + <ErrorDisplay 732 + message="Something went wrong" 733 + detail="Could not load forum structure. Please try again." 734 + /> 735 + </BaseLayout>, 736 + 500 737 + ); 738 + } 739 + 740 + const categoriesData = (await categoriesRes.json()) as { categories: CategoryEntry[] }; 741 + const catList = categoriesData.categories; 742 + 743 + // Fetch boards for each category in parallel (N+1 pattern — same as home.tsx) 744 + let boardsPerCategory: BoardEntry[][]; 745 + try { 746 + boardsPerCategory = await Promise.all( 747 + catList.map((cat) => 748 + fetch(`${appviewUrl}/api/categories/${cat.id}/boards`, { 749 + headers: { Cookie: cookie }, 750 + }) 751 + .then((r) => r.json() as Promise<{ boards: BoardEntry[] }>) 752 + .then((data) => data.boards) 753 + .catch((error) => { 754 + if (isProgrammingError(error)) throw error; 755 + logger.error("Failed to fetch boards for category", { 756 + operation: "GET /admin/structure", 757 + categoryId: cat.id, 758 + error: error instanceof Error ? error.message : String(error), 759 + }); 760 + return [] as BoardEntry[]; 761 + }) 762 + ) 763 + ); 764 + } catch (error) { 765 + if (isProgrammingError(error)) throw error; 766 + logger.error("Failed to fetch boards for all categories", { 767 + operation: "GET /admin/structure", 768 + error: error instanceof Error ? error.message : String(error), 769 + }); 770 + boardsPerCategory = catList.map(() => []); 771 + } 772 + 773 + const structure = catList.map((cat, i) => ({ 774 + category: cat, 775 + boards: boardsPerCategory[i] ?? [], 776 + })); 777 + 778 + return c.html( 779 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 780 + <PageHeader title="Forum Structure" /> 781 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 782 + <div class="structure-page"> 783 + {structure.length === 0 ? ( 784 + <EmptyState message="No categories yet" /> 785 + ) : ( 786 + structure.map(({ category, boards }) => ( 787 + <StructureCategorySection category={category} boards={boards} /> 788 + )) 789 + )} 790 + <div class="structure-add-category card"> 791 + <h3>Add Category</h3> 792 + <form method="post" action="/admin/structure/categories"> 793 + <div class="form-group"> 794 + <label for="new-cat-name">Name</label> 795 + <input id="new-cat-name" type="text" name="name" required /> 796 + </div> 797 + <div class="form-group"> 798 + <label for="new-cat-desc">Description</label> 799 + <textarea id="new-cat-desc" name="description"></textarea> 800 + </div> 801 + <div class="form-group"> 802 + <label for="new-cat-sort">Sort Order</label> 803 + <input id="new-cat-sort" type="number" name="sortOrder" min="0" value="0" /> 804 + </div> 805 + <button type="submit" class="btn btn-primary">Add Category</button> 806 + </form> 807 + </div> 808 + </div> 809 + </BaseLayout> 810 + ); 811 + }); 812 + 813 + // ── POST /admin/structure/categories ───────────────────────────────────── 814 + 815 + app.post("/admin/structure/categories", async (c) => { 816 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 817 + if (!auth.authenticated) return c.redirect("/login"); 818 + if (!canManageCategories(auth)) { 819 + return c.html( 820 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 821 + <PageHeader title="Forum Structure" /> 822 + <p>You don&apos;t have permission to manage forum structure.</p> 823 + </BaseLayout>, 824 + 403 825 + ); 826 + } 827 + 828 + const cookie = c.req.header("cookie") ?? ""; 829 + 830 + let body: Record<string, string | File>; 831 + try { 832 + body = await c.req.parseBody(); 833 + } catch (error) { 834 + if (isProgrammingError(error)) throw error; 835 + return c.redirect( 836 + `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 837 + 302 838 + ); 839 + } 840 + 841 + const name = typeof body.name === "string" ? body.name.trim() : ""; 842 + if (!name) { 843 + return c.redirect( 844 + `/admin/structure?error=${encodeURIComponent("Category name is required.")}`, 845 + 302 846 + ); 847 + } 848 + 849 + const description = typeof body.description === "string" ? body.description.trim() || null : null; 850 + const sortOrder = parseSortOrder(body.sortOrder); 851 + if (sortOrder === null) { 852 + return c.redirect( 853 + `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`, 854 + 302 855 + ); 856 + } 857 + 858 + let appviewRes: Response; 859 + try { 860 + appviewRes = await fetch(`${appviewUrl}/api/admin/categories`, { 861 + method: "POST", 862 + headers: { "Content-Type": "application/json", Cookie: cookie }, 863 + body: JSON.stringify({ name, description, sortOrder }), 864 + }); 865 + } catch (error) { 866 + if (isProgrammingError(error)) throw error; 867 + logger.error("Network error creating category", { 868 + operation: "POST /admin/structure/categories", 869 + error: error instanceof Error ? error.message : String(error), 870 + }); 871 + return c.redirect( 872 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 873 + 302 874 + ); 875 + } 876 + 877 + if (!appviewRes.ok) { 878 + const msg = await extractAppviewError(appviewRes, "Failed to create category. Please try again."); 879 + logger.error("AppView error creating category", { 880 + operation: "POST /admin/structure/categories", 881 + status: appviewRes.status, 882 + }); 883 + return c.redirect( 884 + `/admin/structure?error=${encodeURIComponent(msg)}`, 885 + 302 886 + ); 887 + } 888 + 889 + return c.redirect("/admin/structure", 302); 890 + }); 891 + 892 + // ── POST /admin/structure/categories/:id/edit ───────────────────────────── 893 + 894 + app.post("/admin/structure/categories/:id/edit", async (c) => { 895 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 896 + if (!auth.authenticated) return c.redirect("/login"); 897 + if (!canManageCategories(auth)) { 898 + return c.html( 899 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 900 + <PageHeader title="Forum Structure" /> 901 + <p>You don&apos;t have permission to manage forum structure.</p> 902 + </BaseLayout>, 903 + 403 904 + ); 905 + } 906 + 907 + const categoryId = c.req.param("id"); 908 + const cookie = c.req.header("cookie") ?? ""; 909 + 910 + let body: Record<string, string | File>; 911 + try { 912 + body = await c.req.parseBody(); 913 + } catch (error) { 914 + if (isProgrammingError(error)) throw error; 915 + return c.redirect( 916 + `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 917 + 302 918 + ); 919 + } 920 + 921 + const name = typeof body.name === "string" ? body.name.trim() : ""; 922 + if (!name) { 923 + return c.redirect( 924 + `/admin/structure?error=${encodeURIComponent("Category name is required.")}`, 925 + 302 926 + ); 927 + } 928 + 929 + const description = typeof body.description === "string" ? body.description.trim() || null : null; 930 + const sortOrder = parseSortOrder(body.sortOrder); 931 + if (sortOrder === null) { 932 + return c.redirect( 933 + `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`, 934 + 302 935 + ); 936 + } 937 + 938 + let appviewRes: Response; 939 + try { 940 + appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${categoryId}`, { 941 + method: "PUT", 942 + headers: { "Content-Type": "application/json", Cookie: cookie }, 943 + body: JSON.stringify({ name, description, sortOrder }), 944 + }); 945 + } catch (error) { 946 + if (isProgrammingError(error)) throw error; 947 + logger.error("Network error editing category", { 948 + operation: "POST /admin/structure/categories/:id/edit", 949 + categoryId, 950 + error: error instanceof Error ? error.message : String(error), 951 + }); 952 + return c.redirect( 953 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 954 + 302 955 + ); 956 + } 957 + 958 + if (!appviewRes.ok) { 959 + const msg = await extractAppviewError(appviewRes, "Failed to update category. Please try again."); 960 + logger.error("AppView error editing category", { 961 + operation: "POST /admin/structure/categories/:id/edit", 962 + categoryId, 963 + status: appviewRes.status, 964 + }); 965 + return c.redirect( 966 + `/admin/structure?error=${encodeURIComponent(msg)}`, 967 + 302 968 + ); 969 + } 970 + 971 + return c.redirect("/admin/structure", 302); 972 + }); 973 + 974 + // ── POST /admin/structure/categories/:id/delete ─────────────────────────── 975 + 976 + app.post("/admin/structure/categories/:id/delete", async (c) => { 977 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 978 + if (!auth.authenticated) return c.redirect("/login"); 979 + if (!canManageCategories(auth)) { 980 + return c.html( 981 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 982 + <PageHeader title="Forum Structure" /> 983 + <p>You don&apos;t have permission to manage forum structure.</p> 984 + </BaseLayout>, 985 + 403 986 + ); 987 + } 988 + 989 + const categoryId = c.req.param("id"); 990 + const cookie = c.req.header("cookie") ?? ""; 991 + 992 + let appviewRes: Response; 993 + try { 994 + appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${categoryId}`, { 995 + method: "DELETE", 996 + headers: { Cookie: cookie }, 997 + }); 998 + } catch (error) { 999 + if (isProgrammingError(error)) throw error; 1000 + logger.error("Network error deleting category", { 1001 + operation: "POST /admin/structure/categories/:id/delete", 1002 + categoryId, 1003 + error: error instanceof Error ? error.message : String(error), 1004 + }); 1005 + return c.redirect( 1006 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1007 + 302 1008 + ); 1009 + } 1010 + 1011 + if (!appviewRes.ok) { 1012 + const msg = await extractAppviewError(appviewRes, "Failed to delete category. Please try again."); 1013 + logger.error("AppView error deleting category", { 1014 + operation: "POST /admin/structure/categories/:id/delete", 1015 + categoryId, 1016 + status: appviewRes.status, 1017 + }); 1018 + return c.redirect( 1019 + `/admin/structure?error=${encodeURIComponent(msg)}`, 1020 + 302 1021 + ); 1022 + } 1023 + 1024 + return c.redirect("/admin/structure", 302); 1025 + }); 1026 + 1027 + // ── POST /admin/structure/boards ────────────────────────────────────────── 1028 + 1029 + app.post("/admin/structure/boards", async (c) => { 1030 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1031 + if (!auth.authenticated) return c.redirect("/login"); 1032 + if (!canManageCategories(auth)) { 1033 + return c.html( 1034 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1035 + <PageHeader title="Forum Structure" /> 1036 + <p>You don&apos;t have permission to manage forum structure.</p> 1037 + </BaseLayout>, 1038 + 403 1039 + ); 1040 + } 1041 + 1042 + const cookie = c.req.header("cookie") ?? ""; 1043 + 1044 + let body: Record<string, string | File>; 1045 + try { 1046 + body = await c.req.parseBody(); 1047 + } catch (error) { 1048 + if (isProgrammingError(error)) throw error; 1049 + return c.redirect( 1050 + `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 1051 + 302 1052 + ); 1053 + } 1054 + 1055 + const name = typeof body.name === "string" ? body.name.trim() : ""; 1056 + if (!name) { 1057 + return c.redirect( 1058 + `/admin/structure?error=${encodeURIComponent("Board name is required.")}`, 1059 + 302 1060 + ); 1061 + } 1062 + 1063 + const categoryUri = typeof body.categoryUri === "string" ? body.categoryUri.trim() : ""; 1064 + if (!categoryUri) { 1065 + return c.redirect( 1066 + `/admin/structure?error=${encodeURIComponent("Category is required to create a board.")}`, 1067 + 302 1068 + ); 1069 + } 1070 + 1071 + const description = typeof body.description === "string" ? body.description.trim() || null : null; 1072 + const sortOrder = parseSortOrder(body.sortOrder); 1073 + if (sortOrder === null) { 1074 + return c.redirect( 1075 + `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`, 1076 + 302 1077 + ); 1078 + } 1079 + 1080 + let appviewRes: Response; 1081 + try { 1082 + appviewRes = await fetch(`${appviewUrl}/api/admin/boards`, { 1083 + method: "POST", 1084 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1085 + body: JSON.stringify({ name, description, sortOrder, categoryUri }), 1086 + }); 1087 + } catch (error) { 1088 + if (isProgrammingError(error)) throw error; 1089 + logger.error("Network error creating board", { 1090 + operation: "POST /admin/structure/boards", 1091 + error: error instanceof Error ? error.message : String(error), 1092 + }); 1093 + return c.redirect( 1094 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1095 + 302 1096 + ); 1097 + } 1098 + 1099 + if (!appviewRes.ok) { 1100 + const msg = await extractAppviewError(appviewRes, "Failed to create board. Please try again."); 1101 + logger.error("AppView error creating board", { 1102 + operation: "POST /admin/structure/boards", 1103 + status: appviewRes.status, 1104 + }); 1105 + return c.redirect( 1106 + `/admin/structure?error=${encodeURIComponent(msg)}`, 1107 + 302 1108 + ); 1109 + } 1110 + 1111 + return c.redirect("/admin/structure", 302); 1112 + }); 1113 + 1114 + // ── POST /admin/structure/boards/:id/edit ───────────────────────────────── 1115 + 1116 + app.post("/admin/structure/boards/:id/edit", async (c) => { 1117 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1118 + if (!auth.authenticated) return c.redirect("/login"); 1119 + if (!canManageCategories(auth)) { 1120 + return c.html( 1121 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1122 + <PageHeader title="Forum Structure" /> 1123 + <p>You don&apos;t have permission to manage forum structure.</p> 1124 + </BaseLayout>, 1125 + 403 1126 + ); 1127 + } 1128 + 1129 + const boardId = c.req.param("id"); 1130 + const cookie = c.req.header("cookie") ?? ""; 1131 + 1132 + let body: Record<string, string | File>; 1133 + try { 1134 + body = await c.req.parseBody(); 1135 + } catch (error) { 1136 + if (isProgrammingError(error)) throw error; 1137 + return c.redirect( 1138 + `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 1139 + 302 1140 + ); 1141 + } 1142 + 1143 + const name = typeof body.name === "string" ? body.name.trim() : ""; 1144 + if (!name) { 1145 + return c.redirect( 1146 + `/admin/structure?error=${encodeURIComponent("Board name is required.")}`, 1147 + 302 1148 + ); 1149 + } 1150 + 1151 + const description = typeof body.description === "string" ? body.description.trim() || null : null; 1152 + const sortOrder = parseSortOrder(body.sortOrder); 1153 + if (sortOrder === null) { 1154 + return c.redirect( 1155 + `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`, 1156 + 302 1157 + ); 1158 + } 1159 + 1160 + let appviewRes: Response; 1161 + try { 1162 + appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${boardId}`, { 1163 + method: "PUT", 1164 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1165 + body: JSON.stringify({ name, description, sortOrder }), 1166 + }); 1167 + } catch (error) { 1168 + if (isProgrammingError(error)) throw error; 1169 + logger.error("Network error editing board", { 1170 + operation: "POST /admin/structure/boards/:id/edit", 1171 + boardId, 1172 + error: error instanceof Error ? error.message : String(error), 1173 + }); 1174 + return c.redirect( 1175 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1176 + 302 1177 + ); 1178 + } 1179 + 1180 + if (!appviewRes.ok) { 1181 + const msg = await extractAppviewError(appviewRes, "Failed to update board. Please try again."); 1182 + logger.error("AppView error editing board", { 1183 + operation: "POST /admin/structure/boards/:id/edit", 1184 + boardId, 1185 + status: appviewRes.status, 1186 + }); 1187 + return c.redirect( 1188 + `/admin/structure?error=${encodeURIComponent(msg)}`, 1189 + 302 1190 + ); 1191 + } 1192 + 1193 + return c.redirect("/admin/structure", 302); 1194 + }); 1195 + 1196 + // ── POST /admin/structure/boards/:id/delete ─────────────────────────────── 1197 + 1198 + app.post("/admin/structure/boards/:id/delete", async (c) => { 1199 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1200 + if (!auth.authenticated) return c.redirect("/login"); 1201 + if (!canManageCategories(auth)) { 1202 + return c.html( 1203 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1204 + <PageHeader title="Forum Structure" /> 1205 + <p>You don&apos;t have permission to manage forum structure.</p> 1206 + </BaseLayout>, 1207 + 403 1208 + ); 1209 + } 1210 + 1211 + const boardId = c.req.param("id"); 1212 + const cookie = c.req.header("cookie") ?? ""; 1213 + 1214 + let appviewRes: Response; 1215 + try { 1216 + appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${boardId}`, { 1217 + method: "DELETE", 1218 + headers: { Cookie: cookie }, 1219 + }); 1220 + } catch (error) { 1221 + if (isProgrammingError(error)) throw error; 1222 + logger.error("Network error deleting board", { 1223 + operation: "POST /admin/structure/boards/:id/delete", 1224 + boardId, 1225 + error: error instanceof Error ? error.message : String(error), 1226 + }); 1227 + return c.redirect( 1228 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1229 + 302 1230 + ); 1231 + } 1232 + 1233 + if (!appviewRes.ok) { 1234 + const msg = await extractAppviewError(appviewRes, "Failed to delete board. Please try again."); 1235 + logger.error("AppView error deleting board", { 1236 + operation: "POST /admin/structure/boards/:id/delete", 1237 + boardId, 1238 + status: appviewRes.status, 1239 + }); 1240 + return c.redirect( 1241 + `/admin/structure?error=${encodeURIComponent(msg)}`, 1242 + 302 1243 + ); 1244 + } 1245 + 1246 + return c.redirect("/admin/structure", 302); 477 1247 }); 478 1248 479 1249 return app;
+2
bruno/AppView API/Categories/List Categories.bru
··· 11 11 assert { 12 12 res.status: eq 200 13 13 res.body.categories: isArray 14 + res.body.categories[0].uri: isDefined 14 15 } 15 16 16 17 docs { ··· 22 23 { 23 24 "id": "1", 24 25 "did": "did:plc:...", 26 + "uri": "at://did:plc:.../space.atbb.forum.category/3lbk7...", 25 27 "name": "General", 26 28 "description": "General discussion", 27 29 "slug": "general",
+1893
docs/plans/complete/2026-03-01-atb-47-admin-structure-ui.md
··· 1 + # ATB-47: Admin Structure UI Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add the `/admin/structure` page for full CRUD management of forum categories and boards, with pre-rendered inline edit forms, native `<dialog>` delete confirmation, and redirect-after-POST error surfacing. 6 + 7 + **Architecture:** The web server fetches category list from `GET /api/categories` (N+1 pattern: then one `GET /api/categories/:id/boards` per category in parallel, matching `home.tsx`). Six proxy routes translate HTML form POSTs to the correct AppView JSON API calls (PUT/DELETE). Error messages are passed via `?error=` query param on redirect. One AppView-side prerequisite: add `uri` to `serializeCategory` so "Add Board" forms know the category AT-URI. 8 + 9 + **Tech Stack:** Hono JSX, Vitest (mock-fetch pattern for web tests, real DB for AppView test), native HTML `<dialog>` for delete confirmation, `<details>`/`<summary>` for pre-rendered inline edit forms. CSS tokens in `apps/web/public/static/css/theme.css`. 10 + 11 + **Key files:** 12 + - Modify: `apps/appview/src/routes/helpers.ts` (add `uri` to `serializeCategory`) 13 + - Modify: `apps/appview/src/routes/__tests__/categories.test.ts` (test the new field) 14 + - Modify: `apps/web/src/routes/admin.tsx` (all new web routes + components) 15 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` (all new web tests) 16 + - Modify: `apps/web/public/static/css/theme.css` (structure page styles) 17 + - Modify: `bruno/AppView API/Categories/List Categories.bru` (document new `uri` field) 18 + 19 + --- 20 + 21 + ## Task 1: Add `uri` to `serializeCategory` (AppView prerequisite) 22 + 23 + The "Add Board" inline forms need the AT-URI of each parent category to pass as `categoryUri`. Currently `serializeCategory` omits the URI even though the DB row has `rkey`. This is a non-breaking additive change to the public API. 24 + 25 + **Files:** 26 + - Modify: `apps/appview/src/routes/helpers.ts` (~line 281) 27 + - Modify: `apps/appview/src/routes/__tests__/categories.test.ts` 28 + 29 + **Step 1: Find the existing test that checks `GET /api/categories` response shape** 30 + 31 + ```bash 32 + grep -n "serializes each category\|id.*string\|name.*string" \ 33 + apps/appview/src/routes/__tests__/categories.test.ts 34 + ``` 35 + 36 + You'll see a test called `"serializes each category with correct types"` that inserts a row and checks fields. This is the test to extend. 37 + 38 + **Step 2: Add a failing assertion for `uri`** 39 + 40 + In the existing `"serializes each category with correct types"` test, add after the existing field assertions: 41 + 42 + ```typescript 43 + // In apps/appview/src/routes/__tests__/categories.test.ts 44 + // Find the test that checks the response shape and add: 45 + expect(category.uri).toMatch(/^at:\/\/did:plc:/); 46 + expect(category.uri).toContain("/space.atbb.forum.category/"); 47 + ``` 48 + 49 + **Step 3: Run the failing test** 50 + 51 + ```bash 52 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run \ 53 + src/routes/__tests__/categories.test.ts 54 + ``` 55 + 56 + Expected: FAIL — `expect(undefined).toMatch(...)`. If the test passes, the field was already added; skip to Task 2. 57 + 58 + **Step 4: Add `uri` to `serializeCategory`** 59 + 60 + In `apps/appview/src/routes/helpers.ts`, find `serializeCategory` (~line 281) and add the `uri` field: 61 + 62 + ```typescript 63 + export function serializeCategory(cat: CategoryRow) { 64 + return { 65 + id: serializeBigInt(cat.id), 66 + did: cat.did, 67 + uri: `at://${cat.did}/space.atbb.forum.category/${cat.rkey}`, // ← ADD THIS 68 + name: cat.name, 69 + description: cat.description, 70 + slug: cat.slug, 71 + sortOrder: cat.sortOrder, 72 + forumId: serializeBigInt(cat.forumId), 73 + createdAt: serializeDate(cat.createdAt), 74 + indexedAt: serializeDate(cat.indexedAt), 75 + }; 76 + } 77 + ``` 78 + 79 + **Step 5: Run the test to verify it passes** 80 + 81 + ```bash 82 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run \ 83 + src/routes/__tests__/categories.test.ts 84 + ``` 85 + 86 + Expected: PASS. 87 + 88 + **Step 6: Update Bruno docs to document the new field** 89 + 90 + In `bruno/AppView API/Categories/List Categories.bru`, add `uri` to the response documentation in the `docs {}` block and add an assertion: 91 + 92 + ``` 93 + assert { 94 + res.status: eq 200 95 + res.body.categories: isDefined 96 + } 97 + ``` 98 + 99 + Add to the docs block a note that each category now includes `uri: "at://..."`. 100 + 101 + **Step 7: Commit** 102 + 103 + ```bash 104 + git add apps/appview/src/routes/helpers.ts \ 105 + apps/appview/src/routes/__tests__/categories.test.ts \ 106 + bruno/AppView\ API/Categories/List\ Categories.bru 107 + git commit -m "feat(appview): add uri field to serializeCategory (ATB-47)" 108 + ``` 109 + 110 + --- 111 + 112 + ## Task 2: Types and failing tests for `GET /admin/structure` 113 + 114 + Add TypeScript types to `admin.tsx` and write ALL failing tests for the structure page before implementing it. 115 + 116 + **Files:** 117 + - Modify: `apps/web/src/routes/admin.tsx` (add types only — no route yet) 118 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` (new describe block) 119 + 120 + **Step 1: Add types to `admin.tsx`** 121 + 122 + At the top of `apps/web/src/routes/admin.tsx`, alongside `MemberEntry` and `RoleEntry`, add: 123 + 124 + ```typescript 125 + interface CategoryEntry { 126 + id: string; 127 + did: string; 128 + uri: string; 129 + name: string; 130 + description: string | null; 131 + sortOrder: number | null; 132 + } 133 + 134 + interface BoardEntry { 135 + id: string; 136 + name: string; 137 + description: string | null; 138 + sortOrder: number | null; 139 + categoryUri: string; 140 + uri: string; 141 + } 142 + ``` 143 + 144 + **Step 2: Write failing tests** 145 + 146 + At the bottom of `apps/web/src/routes/__tests__/admin.test.tsx`, add a new describe block. The mock-fetch pattern here matches the existing tests — each authenticated request costs 2 mock calls (session + permissions), then data fetches follow. 147 + 148 + ```typescript 149 + describe("createAdminRoutes — GET /admin/structure", () => { 150 + beforeEach(() => { 151 + vi.stubGlobal("fetch", mockFetch); 152 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 153 + vi.resetModules(); 154 + }); 155 + 156 + afterEach(() => { 157 + vi.unstubAllGlobals(); 158 + vi.unstubAllEnvs(); 159 + mockFetch.mockReset(); 160 + }); 161 + 162 + function mockResponse(body: unknown, ok = true, status = 200) { 163 + return { 164 + ok, 165 + status, 166 + statusText: ok ? "OK" : "Error", 167 + json: () => Promise.resolve(body), 168 + }; 169 + } 170 + 171 + function setupSession(permissions: string[]) { 172 + mockFetch.mockResolvedValueOnce( 173 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 174 + ); 175 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 176 + } 177 + 178 + /** 179 + * Sets up mock responses for the structure page data fetches. 180 + * After the 2 session calls: 181 + * Call 3: GET /api/categories 182 + * Call 4+: GET /api/categories/:id/boards (one per category, parallel) 183 + */ 184 + function setupStructureFetch( 185 + cats: Array<{ id: string; name: string; uri: string; sortOrder?: number }>, 186 + boardsByCategory: Record<string, Array<{ id: string; name: string }>> = {} 187 + ) { 188 + mockFetch.mockResolvedValueOnce( 189 + mockResponse({ 190 + categories: cats.map((c) => ({ 191 + id: c.id, 192 + did: "did:plc:forum", 193 + uri: c.uri, 194 + name: c.name, 195 + description: null, 196 + slug: null, 197 + sortOrder: c.sortOrder ?? 1, 198 + forumId: "1", 199 + createdAt: "2025-01-01T00:00:00.000Z", 200 + indexedAt: "2025-01-01T00:00:00.000Z", 201 + })), 202 + }) 203 + ); 204 + for (const cat of cats) { 205 + const boards = boardsByCategory[cat.id] ?? []; 206 + mockFetch.mockResolvedValueOnce( 207 + mockResponse({ 208 + boards: boards.map((b) => ({ 209 + id: b.id, 210 + did: "did:plc:forum", 211 + uri: `at://did:plc:forum/space.atbb.forum.board/${b.id}`, 212 + name: b.name, 213 + description: null, 214 + slug: null, 215 + sortOrder: 1, 216 + categoryId: cat.id, 217 + categoryUri: cat.uri, 218 + createdAt: "2025-01-01T00:00:00.000Z", 219 + indexedAt: "2025-01-01T00:00:00.000Z", 220 + })), 221 + }) 222 + ); 223 + } 224 + } 225 + 226 + async function loadAdminRoutes() { 227 + const { createAdminRoutes } = await import("../admin.js"); 228 + return createAdminRoutes("http://localhost:3000"); 229 + } 230 + 231 + it("redirects unauthenticated users to /login", async () => { 232 + const routes = await loadAdminRoutes(); 233 + const res = await routes.request("/admin/structure"); 234 + expect(res.status).toBe(302); 235 + expect(res.headers.get("location")).toBe("/login"); 236 + }); 237 + 238 + it("returns 403 for authenticated user without manageCategories", async () => { 239 + setupSession(["space.atbb.permission.manageMembers"]); 240 + const routes = await loadAdminRoutes(); 241 + const res = await routes.request("/admin/structure", { 242 + headers: { cookie: "atbb_session=token" }, 243 + }); 244 + expect(res.status).toBe(403); 245 + }); 246 + 247 + it("renders structure page with category and board names", async () => { 248 + setupSession(["space.atbb.permission.manageCategories"]); 249 + setupStructureFetch( 250 + [{ id: "1", name: "General Discussion", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 251 + { "1": [{ id: "10", name: "General Chat" }] } 252 + ); 253 + 254 + const routes = await loadAdminRoutes(); 255 + const res = await routes.request("/admin/structure", { 256 + headers: { cookie: "atbb_session=token" }, 257 + }); 258 + 259 + expect(res.status).toBe(200); 260 + const html = await res.text(); 261 + expect(html).toContain("General Discussion"); 262 + expect(html).toContain("General Chat"); 263 + }); 264 + 265 + it("renders empty state when no categories exist", async () => { 266 + setupSession(["space.atbb.permission.manageCategories"]); 267 + setupStructureFetch([]); 268 + 269 + const routes = await loadAdminRoutes(); 270 + const res = await routes.request("/admin/structure", { 271 + headers: { cookie: "atbb_session=token" }, 272 + }); 273 + 274 + expect(res.status).toBe(200); 275 + const html = await res.text(); 276 + expect(html).toContain("No categories"); 277 + }); 278 + 279 + it("renders the add-category form", async () => { 280 + setupSession(["space.atbb.permission.manageCategories"]); 281 + setupStructureFetch([]); 282 + 283 + const routes = await loadAdminRoutes(); 284 + const res = await routes.request("/admin/structure", { 285 + headers: { cookie: "atbb_session=token" }, 286 + }); 287 + 288 + const html = await res.text(); 289 + expect(html).toContain('action="/admin/structure/categories"'); 290 + }); 291 + 292 + it("renders edit and delete actions for a category", async () => { 293 + setupSession(["space.atbb.permission.manageCategories"]); 294 + setupStructureFetch( 295 + [{ id: "5", name: "Projects", uri: "at://did:plc:forum/space.atbb.forum.category/xyz" }], 296 + ); 297 + 298 + const routes = await loadAdminRoutes(); 299 + const res = await routes.request("/admin/structure", { 300 + headers: { cookie: "atbb_session=token" }, 301 + }); 302 + 303 + const html = await res.text(); 304 + expect(html).toContain('action="/admin/structure/categories/5/edit"'); 305 + expect(html).toContain('action="/admin/structure/categories/5/delete"'); 306 + }); 307 + 308 + it("renders edit and delete actions for a board", async () => { 309 + setupSession(["space.atbb.permission.manageCategories"]); 310 + setupStructureFetch( 311 + [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 312 + { "1": [{ id: "20", name: "Showcase" }] } 313 + ); 314 + 315 + const routes = await loadAdminRoutes(); 316 + const res = await routes.request("/admin/structure", { 317 + headers: { cookie: "atbb_session=token" }, 318 + }); 319 + 320 + const html = await res.text(); 321 + expect(html).toContain("Showcase"); 322 + expect(html).toContain('action="/admin/structure/boards/20/edit"'); 323 + expect(html).toContain('action="/admin/structure/boards/20/delete"'); 324 + }); 325 + 326 + it("renders add-board form with categoryUri hidden input", async () => { 327 + setupSession(["space.atbb.permission.manageCategories"]); 328 + setupStructureFetch( 329 + [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 330 + ); 331 + 332 + const routes = await loadAdminRoutes(); 333 + const res = await routes.request("/admin/structure", { 334 + headers: { cookie: "atbb_session=token" }, 335 + }); 336 + 337 + const html = await res.text(); 338 + expect(html).toContain('name="categoryUri"'); 339 + expect(html).toContain('value="at://did:plc:forum/space.atbb.forum.category/abc"'); 340 + expect(html).toContain('action="/admin/structure/boards"'); 341 + }); 342 + 343 + it("renders error banner when ?error= query param is present", async () => { 344 + setupSession(["space.atbb.permission.manageCategories"]); 345 + setupStructureFetch([]); 346 + 347 + const routes = await loadAdminRoutes(); 348 + const res = await routes.request( 349 + `/admin/structure?error=${encodeURIComponent("Cannot delete category with boards. Remove all boards first.")}`, 350 + { headers: { cookie: "atbb_session=token" } } 351 + ); 352 + 353 + const html = await res.text(); 354 + expect(html).toContain("Cannot delete category with boards"); 355 + }); 356 + 357 + it("returns 503 on AppView network error fetching categories", async () => { 358 + setupSession(["space.atbb.permission.manageCategories"]); 359 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 360 + 361 + const routes = await loadAdminRoutes(); 362 + const res = await routes.request("/admin/structure", { 363 + headers: { cookie: "atbb_session=token" }, 364 + }); 365 + 366 + expect(res.status).toBe(503); 367 + const html = await res.text(); 368 + expect(html).toContain("error-display"); 369 + }); 370 + 371 + it("returns 500 on AppView server error fetching categories", async () => { 372 + setupSession(["space.atbb.permission.manageCategories"]); 373 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 374 + 375 + const routes = await loadAdminRoutes(); 376 + const res = await routes.request("/admin/structure", { 377 + headers: { cookie: "atbb_session=token" }, 378 + }); 379 + 380 + expect(res.status).toBe(500); 381 + const html = await res.text(); 382 + expect(html).toContain("error-display"); 383 + }); 384 + 385 + it("redirects to /login when AppView categories returns 401", async () => { 386 + setupSession(["space.atbb.permission.manageCategories"]); 387 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 388 + 389 + const routes = await loadAdminRoutes(); 390 + const res = await routes.request("/admin/structure", { 391 + headers: { cookie: "atbb_session=token" }, 392 + }); 393 + 394 + expect(res.status).toBe(302); 395 + expect(res.headers.get("location")).toBe("/login"); 396 + }); 397 + }); 398 + ``` 399 + 400 + **Step 3: Run the failing tests** 401 + 402 + ```bash 403 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \ 404 + src/routes/__tests__/admin.test.tsx 405 + ``` 406 + 407 + Expected: All `GET /admin/structure` tests FAIL with "route not found" or similar. 408 + 409 + **Step 4: Commit the types and tests (no implementation yet)** 410 + 411 + ```bash 412 + git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx 413 + git commit -m "test(web): add failing tests for GET /admin/structure (ATB-47)" 414 + ``` 415 + 416 + --- 417 + 418 + ## Task 3: Implement `GET /admin/structure` (page render) 419 + 420 + **Files:** 421 + - Modify: `apps/web/src/routes/admin.tsx` 422 + 423 + **Step 1: Add a local helper for error message extraction** 424 + 425 + Before the `createAdminRoutes` function in `admin.tsx`, add this private helper (used by 6 proxy routes later): 426 + 427 + ```typescript 428 + /** 429 + * Extracts the error message from an AppView error response. 430 + * Falls back to the provided default if JSON parsing fails. 431 + */ 432 + async function extractAppviewError(res: Response, fallback: string): Promise<string> { 433 + try { 434 + const data = (await res.json()) as { error?: string }; 435 + return data.error ?? fallback; 436 + } catch { 437 + return fallback; 438 + } 439 + } 440 + 441 + /** 442 + * Parses a sort order value from a form field string. 443 + * Returns 0 for invalid or missing values. 444 + */ 445 + function parseSortOrder(value: unknown): number { 446 + if (typeof value !== "string") return 0; 447 + const n = parseInt(value, 10); 448 + return Number.isFinite(n) && n >= 0 ? n : 0; 449 + } 450 + ``` 451 + 452 + **Step 2: Add structure page components** 453 + 454 + Inside `createAdminRoutes` (before the `app.get("/admin")` route), add these JSX components. Keep them as local functions — they're used only on this page. 455 + 456 + ```typescript 457 + // ─── Structure Page Components ────────────────────────────────────────── 458 + 459 + function StructureBoardRow({ board }: { board: BoardEntry }) { 460 + const dialogId = `confirm-delete-board-${board.id}`; 461 + return ( 462 + <div class="structure-board"> 463 + <div class="structure-board__header"> 464 + <span class="structure-board__name">{board.name}</span> 465 + <span class="structure-board__meta">sortOrder: {board.sortOrder ?? 0}</span> 466 + <div class="structure-board__actions"> 467 + <button 468 + type="button" 469 + class="btn btn-secondary btn-sm" 470 + onclick={`document.getElementById('edit-board-${board.id}').open=!document.getElementById('edit-board-${board.id}').open`} 471 + > 472 + Edit 473 + </button> 474 + <button 475 + type="button" 476 + class="btn btn-danger btn-sm" 477 + onclick={`document.getElementById('${dialogId}').showModal()`} 478 + > 479 + Delete 480 + </button> 481 + </div> 482 + </div> 483 + <details id={`edit-board-${board.id}`} class="structure-edit-form"> 484 + <summary class="sr-only">Edit {board.name}</summary> 485 + <form method="POST" action={`/admin/structure/boards/${board.id}/edit`} class="structure-edit-form__body"> 486 + <div class="form-group"> 487 + <label for={`edit-board-name-${board.id}`}>Name</label> 488 + <input id={`edit-board-name-${board.id}`} type="text" name="name" value={board.name} required /> 489 + </div> 490 + <div class="form-group"> 491 + <label for={`edit-board-desc-${board.id}`}>Description</label> 492 + <textarea id={`edit-board-desc-${board.id}`} name="description">{board.description ?? ""}</textarea> 493 + </div> 494 + <div class="form-group"> 495 + <label for={`edit-board-sort-${board.id}`}>Sort Order</label> 496 + <input id={`edit-board-sort-${board.id}`} type="number" name="sortOrder" min="0" value={String(board.sortOrder ?? 0)} /> 497 + </div> 498 + <button type="submit" class="btn btn-primary">Save Changes</button> 499 + </form> 500 + </details> 501 + <dialog id={dialogId} class="structure-confirm-dialog"> 502 + <p>Delete board &quot;{board.name}&quot;? This cannot be undone.</p> 503 + <form method="POST" action={`/admin/structure/boards/${board.id}/delete`} class="dialog-actions"> 504 + <button type="submit" class="btn btn-danger">Delete</button> 505 + <button 506 + type="button" 507 + class="btn btn-secondary" 508 + onclick={`document.getElementById('${dialogId}').close()`} 509 + > 510 + Cancel 511 + </button> 512 + </form> 513 + </dialog> 514 + </div> 515 + ); 516 + } 517 + 518 + function StructureCategorySection({ 519 + category, 520 + boards, 521 + }: { 522 + category: CategoryEntry; 523 + boards: BoardEntry[]; 524 + }) { 525 + const dialogId = `confirm-delete-category-${category.id}`; 526 + return ( 527 + <div class="structure-category"> 528 + <div class="structure-category__header"> 529 + <span class="structure-category__name">{category.name}</span> 530 + <span class="structure-category__meta">sortOrder: {category.sortOrder ?? 0}</span> 531 + <div class="structure-category__actions"> 532 + <button 533 + type="button" 534 + class="btn btn-secondary btn-sm" 535 + onclick={`document.getElementById('edit-category-${category.id}').open=!document.getElementById('edit-category-${category.id}').open`} 536 + > 537 + Edit 538 + </button> 539 + <button 540 + type="button" 541 + class="btn btn-danger btn-sm" 542 + onclick={`document.getElementById('${dialogId}').showModal()`} 543 + > 544 + Delete 545 + </button> 546 + </div> 547 + </div> 548 + 549 + {/* Inline edit form — pre-rendered, hidden until Edit clicked */} 550 + <details id={`edit-category-${category.id}`} class="structure-edit-form"> 551 + <summary class="sr-only">Edit {category.name}</summary> 552 + <form method="POST" action={`/admin/structure/categories/${category.id}/edit`} class="structure-edit-form__body"> 553 + <div class="form-group"> 554 + <label for={`edit-cat-name-${category.id}`}>Name</label> 555 + <input id={`edit-cat-name-${category.id}`} type="text" name="name" value={category.name} required /> 556 + </div> 557 + <div class="form-group"> 558 + <label for={`edit-cat-desc-${category.id}`}>Description</label> 559 + <textarea id={`edit-cat-desc-${category.id}`} name="description">{category.description ?? ""}</textarea> 560 + </div> 561 + <div class="form-group"> 562 + <label for={`edit-cat-sort-${category.id}`}>Sort Order</label> 563 + <input id={`edit-cat-sort-${category.id}`} type="number" name="sortOrder" min="0" value={String(category.sortOrder ?? 0)} /> 564 + </div> 565 + <button type="submit" class="btn btn-primary">Save Changes</button> 566 + </form> 567 + </details> 568 + 569 + {/* Delete confirmation dialog */} 570 + <dialog id={dialogId} class="structure-confirm-dialog"> 571 + <p>Delete category &quot;{category.name}&quot;? All boards must be removed first.</p> 572 + <form method="POST" action={`/admin/structure/categories/${category.id}/delete`} class="dialog-actions"> 573 + <button type="submit" class="btn btn-danger">Delete</button> 574 + <button 575 + type="button" 576 + class="btn btn-secondary" 577 + onclick={`document.getElementById('${dialogId}').close()`} 578 + > 579 + Cancel 580 + </button> 581 + </form> 582 + </dialog> 583 + 584 + {/* Boards nested beneath category */} 585 + <div class="structure-boards"> 586 + {boards.map((board) => ( 587 + <StructureBoardRow board={board} /> 588 + ))} 589 + <details class="structure-add-board"> 590 + <summary class="structure-add-board__trigger">+ Add Board</summary> 591 + <form method="POST" action="/admin/structure/boards" class="structure-edit-form__body"> 592 + <input type="hidden" name="categoryUri" value={category.uri} /> 593 + <div class="form-group"> 594 + <label for={`new-board-name-${category.id}`}>Name</label> 595 + <input id={`new-board-name-${category.id}`} type="text" name="name" required /> 596 + </div> 597 + <div class="form-group"> 598 + <label for={`new-board-desc-${category.id}`}>Description</label> 599 + <textarea id={`new-board-desc-${category.id}`} name="description"></textarea> 600 + </div> 601 + <div class="form-group"> 602 + <label for={`new-board-sort-${category.id}`}>Sort Order</label> 603 + <input id={`new-board-sort-${category.id}`} type="number" name="sortOrder" min="0" value="0" /> 604 + </div> 605 + <button type="submit" class="btn btn-primary">Add Board</button> 606 + </form> 607 + </details> 608 + </div> 609 + </div> 610 + ); 611 + } 612 + ``` 613 + 614 + **Step 3: Add the `GET /admin/structure` route** 615 + 616 + Add this route inside `createAdminRoutes`, after the members routes and before `return app`: 617 + 618 + ```typescript 619 + // ── GET /admin/structure ───────────────────────────────────────────────── 620 + 621 + app.get("/admin/structure", async (c) => { 622 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 623 + 624 + if (!auth.authenticated) { 625 + return c.redirect("/login"); 626 + } 627 + 628 + if (!canManageCategories(auth)) { 629 + return c.html( 630 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 631 + <PageHeader title="Forum Structure" /> 632 + <p>You don&apos;t have permission to manage forum structure.</p> 633 + </BaseLayout>, 634 + 403 635 + ); 636 + } 637 + 638 + const cookie = c.req.header("cookie") ?? ""; 639 + const errorMsg = c.req.query("error") ?? null; 640 + 641 + // Fetch category list 642 + let categoriesRes: Response; 643 + try { 644 + categoriesRes = await fetch(`${appviewUrl}/api/categories`, { 645 + headers: { Cookie: cookie }, 646 + }); 647 + } catch (error) { 648 + if (isProgrammingError(error)) throw error; 649 + logger.error("Network error fetching categories for structure page", { 650 + operation: "GET /admin/structure", 651 + error: error instanceof Error ? error.message : String(error), 652 + }); 653 + return c.html( 654 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 655 + <PageHeader title="Forum Structure" /> 656 + <ErrorDisplay 657 + message="Unable to load forum structure" 658 + detail="The forum is temporarily unavailable. Please try again." 659 + /> 660 + </BaseLayout>, 661 + 503 662 + ); 663 + } 664 + 665 + if (!categoriesRes.ok) { 666 + if (categoriesRes.status === 401) { 667 + return c.redirect("/login"); 668 + } 669 + logger.error("AppView returned error for categories list", { 670 + operation: "GET /admin/structure", 671 + status: categoriesRes.status, 672 + }); 673 + return c.html( 674 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 675 + <PageHeader title="Forum Structure" /> 676 + <ErrorDisplay 677 + message="Something went wrong" 678 + detail="Could not load forum structure. Please try again." 679 + /> 680 + </BaseLayout>, 681 + 500 682 + ); 683 + } 684 + 685 + const categoriesData = (await categoriesRes.json()) as { categories: CategoryEntry[] }; 686 + const catList = categoriesData.categories; 687 + 688 + // Fetch boards for each category in parallel (N+1 pattern — same as home.tsx) 689 + let boardsPerCategory: BoardEntry[][]; 690 + try { 691 + boardsPerCategory = await Promise.all( 692 + catList.map((cat) => 693 + fetch(`${appviewUrl}/api/categories/${cat.id}/boards`, { 694 + headers: { Cookie: cookie }, 695 + }) 696 + .then((r) => r.json() as Promise<{ boards: BoardEntry[] }>) 697 + .then((data) => data.boards) 698 + .catch(() => [] as BoardEntry[]) // Degrade gracefully per-category on failure 699 + ) 700 + ); 701 + } catch (error) { 702 + if (isProgrammingError(error)) throw error; 703 + boardsPerCategory = catList.map(() => []); 704 + } 705 + 706 + const structure = catList.map((cat, i) => ({ 707 + category: cat, 708 + boards: boardsPerCategory[i] ?? [], 709 + })); 710 + 711 + return c.html( 712 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 713 + <PageHeader title="Forum Structure" /> 714 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 715 + <div class="structure-page"> 716 + {structure.length === 0 ? ( 717 + <EmptyState message="No categories yet" /> 718 + ) : ( 719 + structure.map(({ category, boards }) => ( 720 + <StructureCategorySection category={category} boards={boards} /> 721 + )) 722 + )} 723 + <div class="structure-add-category card"> 724 + <h3>Add Category</h3> 725 + <form method="POST" action="/admin/structure/categories"> 726 + <div class="form-group"> 727 + <label for="new-cat-name">Name</label> 728 + <input id="new-cat-name" type="text" name="name" required /> 729 + </div> 730 + <div class="form-group"> 731 + <label for="new-cat-desc">Description</label> 732 + <textarea id="new-cat-desc" name="description"></textarea> 733 + </div> 734 + <div class="form-group"> 735 + <label for="new-cat-sort">Sort Order</label> 736 + <input id="new-cat-sort" type="number" name="sortOrder" min="0" value="0" /> 737 + </div> 738 + <button type="submit" class="btn btn-primary">Add Category</button> 739 + </form> 740 + </div> 741 + </div> 742 + </BaseLayout> 743 + ); 744 + }); 745 + ``` 746 + 747 + **Step 4: Run the structure page tests** 748 + 749 + ```bash 750 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \ 751 + src/routes/__tests__/admin.test.tsx 752 + ``` 753 + 754 + Expected: All `GET /admin/structure` tests PASS. Earlier tests still pass. 755 + 756 + **Step 5: Commit** 757 + 758 + ```bash 759 + git add apps/web/src/routes/admin.tsx 760 + git commit -m "feat(web): add GET /admin/structure page with category/board listing (ATB-47)" 761 + ``` 762 + 763 + --- 764 + 765 + ## Task 4: Failing tests for category proxy routes 766 + 767 + Write ALL failing tests for the three category proxy routes before implementing them. 768 + 769 + **Files:** 770 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` 771 + 772 + **Step 1: Add failing tests for POST /admin/structure/categories (create)** 773 + 774 + ```typescript 775 + describe("createAdminRoutes — POST /admin/structure/categories", () => { 776 + beforeEach(() => { 777 + vi.stubGlobal("fetch", mockFetch); 778 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 779 + vi.resetModules(); 780 + }); 781 + 782 + afterEach(() => { 783 + vi.unstubAllGlobals(); 784 + vi.unstubAllEnvs(); 785 + mockFetch.mockReset(); 786 + }); 787 + 788 + function mockResponse(body: unknown, ok = true, status = 200) { 789 + return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 790 + } 791 + 792 + function setupSession(permissions: string[]) { 793 + mockFetch.mockResolvedValueOnce( 794 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 795 + ); 796 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 797 + } 798 + 799 + async function loadAdminRoutes() { 800 + const { createAdminRoutes } = await import("../admin.js"); 801 + return createAdminRoutes("http://localhost:3000"); 802 + } 803 + 804 + it("redirects to /admin/structure on success", async () => { 805 + setupSession(["space.atbb.permission.manageCategories"]); 806 + mockFetch.mockResolvedValueOnce( 807 + mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.category/abc", cid: "bafyrei..." }, true, 201) 808 + ); 809 + 810 + const routes = await loadAdminRoutes(); 811 + const res = await routes.request("/admin/structure/categories", { 812 + method: "POST", 813 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 814 + body: new URLSearchParams({ name: "New Category", description: "Desc", sortOrder: "1" }).toString(), 815 + }); 816 + 817 + expect(res.status).toBe(302); 818 + expect(res.headers.get("location")).toBe("/admin/structure"); 819 + // Confirm the AppView POST was called with JSON body 820 + expect(mockFetch).toHaveBeenCalledWith( 821 + expect.stringContaining("/api/admin/categories"), 822 + expect.objectContaining({ method: "POST" }) 823 + ); 824 + }); 825 + 826 + it("redirects with ?error= and makes no AppView call when name is empty", async () => { 827 + setupSession(["space.atbb.permission.manageCategories"]); 828 + 829 + const routes = await loadAdminRoutes(); 830 + const res = await routes.request("/admin/structure/categories", { 831 + method: "POST", 832 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 833 + body: new URLSearchParams({ name: "", description: "" }).toString(), 834 + }); 835 + 836 + expect(res.status).toBe(302); 837 + const location = res.headers.get("location") ?? ""; 838 + expect(location).toMatch(/\/admin\/structure\?error=/); 839 + // Only 2 session fetch calls made, no AppView call 840 + expect(mockFetch).toHaveBeenCalledTimes(2); 841 + }); 842 + 843 + it("redirects with ?error= on AppView 409", async () => { 844 + setupSession(["space.atbb.permission.manageCategories"]); 845 + mockFetch.mockResolvedValueOnce( 846 + mockResponse({ error: "Conflict error" }, false, 409) 847 + ); 848 + 849 + const routes = await loadAdminRoutes(); 850 + const res = await routes.request("/admin/structure/categories", { 851 + method: "POST", 852 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 853 + body: new URLSearchParams({ name: "Test", description: "" }).toString(), 854 + }); 855 + 856 + expect(res.status).toBe(302); 857 + const location = res.headers.get("location") ?? ""; 858 + expect(location).toMatch(/\/admin\/structure\?error=/); 859 + }); 860 + 861 + it("redirects to /login on AppView 401", async () => { 862 + setupSession(["space.atbb.permission.manageCategories"]); 863 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 864 + 865 + const routes = await loadAdminRoutes(); 866 + const res = await routes.request("/admin/structure/categories", { 867 + method: "POST", 868 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 869 + body: new URLSearchParams({ name: "Test", description: "" }).toString(), 870 + }); 871 + 872 + expect(res.status).toBe(302); 873 + expect(res.headers.get("location")).toBe("/login"); 874 + }); 875 + 876 + it("redirects with 'temporarily unavailable' error on network failure", async () => { 877 + setupSession(["space.atbb.permission.manageCategories"]); 878 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 879 + 880 + const routes = await loadAdminRoutes(); 881 + const res = await routes.request("/admin/structure/categories", { 882 + method: "POST", 883 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 884 + body: new URLSearchParams({ name: "Test", description: "" }).toString(), 885 + }); 886 + 887 + expect(res.status).toBe(302); 888 + const location = res.headers.get("location") ?? ""; 889 + expect(decodeURIComponent(location)).toContain("unavailable"); 890 + }); 891 + 892 + it("returns 403 for authenticated user without manageCategories", async () => { 893 + setupSession(["space.atbb.permission.manageMembers"]); 894 + 895 + const routes = await loadAdminRoutes(); 896 + const res = await routes.request("/admin/structure/categories", { 897 + method: "POST", 898 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 899 + body: new URLSearchParams({ name: "Test" }).toString(), 900 + }); 901 + 902 + expect(res.status).toBe(403); 903 + }); 904 + 905 + it("redirects unauthenticated to /login", async () => { 906 + const routes = await loadAdminRoutes(); 907 + const res = await routes.request("/admin/structure/categories", { 908 + method: "POST", 909 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 910 + body: new URLSearchParams({ name: "Test" }).toString(), 911 + }); 912 + 913 + expect(res.status).toBe(302); 914 + expect(res.headers.get("location")).toBe("/login"); 915 + }); 916 + }); 917 + 918 + describe("createAdminRoutes — POST /admin/structure/categories/:id/edit", () => { 919 + // (same beforeEach/afterEach/helpers as above — copy them in) 920 + 921 + it("redirects to /admin/structure on success and calls AppView PUT", async () => { 922 + setupSession(["space.atbb.permission.manageCategories"]); 923 + mockFetch.mockResolvedValueOnce( 924 + mockResponse({ uri: "at://...", cid: "baf..." }, true, 200) 925 + ); 926 + 927 + const routes = await loadAdminRoutes(); 928 + const res = await routes.request("/admin/structure/categories/5/edit", { 929 + method: "POST", 930 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 931 + body: new URLSearchParams({ name: "Updated Name", sortOrder: "2" }).toString(), 932 + }); 933 + 934 + expect(res.status).toBe(302); 935 + expect(res.headers.get("location")).toBe("/admin/structure"); 936 + expect(mockFetch).toHaveBeenCalledWith( 937 + expect.stringContaining("/api/admin/categories/5"), 938 + expect.objectContaining({ method: "PUT" }) 939 + ); 940 + }); 941 + 942 + it("redirects with ?error= when name is empty", async () => { 943 + setupSession(["space.atbb.permission.manageCategories"]); 944 + 945 + const routes = await loadAdminRoutes(); 946 + const res = await routes.request("/admin/structure/categories/5/edit", { 947 + method: "POST", 948 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 949 + body: new URLSearchParams({ name: "", sortOrder: "1" }).toString(), 950 + }); 951 + 952 + expect(res.status).toBe(302); 953 + expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/); 954 + expect(mockFetch).toHaveBeenCalledTimes(2); // only session calls 955 + }); 956 + 957 + it("redirects with ?error= on AppView 404", async () => { 958 + setupSession(["space.atbb.permission.manageCategories"]); 959 + mockFetch.mockResolvedValueOnce(mockResponse({ error: "Category not found" }, false, 404)); 960 + 961 + const routes = await loadAdminRoutes(); 962 + const res = await routes.request("/admin/structure/categories/999/edit", { 963 + method: "POST", 964 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 965 + body: new URLSearchParams({ name: "Test" }).toString(), 966 + }); 967 + 968 + expect(res.status).toBe(302); 969 + const location = res.headers.get("location") ?? ""; 970 + expect(decodeURIComponent(location)).toContain("not found"); 971 + }); 972 + }); 973 + 974 + describe("createAdminRoutes — POST /admin/structure/categories/:id/delete", () => { 975 + // (same beforeEach/afterEach/helpers) 976 + 977 + it("redirects to /admin/structure on success and calls AppView DELETE", async () => { 978 + setupSession(["space.atbb.permission.manageCategories"]); 979 + mockFetch.mockResolvedValueOnce(mockResponse({ success: true }, true, 200)); 980 + 981 + const routes = await loadAdminRoutes(); 982 + const res = await routes.request("/admin/structure/categories/5/delete", { 983 + method: "POST", 984 + headers: { cookie: "atbb_session=token" }, 985 + }); 986 + 987 + expect(res.status).toBe(302); 988 + expect(res.headers.get("location")).toBe("/admin/structure"); 989 + expect(mockFetch).toHaveBeenCalledWith( 990 + expect.stringContaining("/api/admin/categories/5"), 991 + expect.objectContaining({ method: "DELETE" }) 992 + ); 993 + }); 994 + 995 + it("redirects with the AppView's 409 error message on referential integrity failure", async () => { 996 + setupSession(["space.atbb.permission.manageCategories"]); 997 + mockFetch.mockResolvedValueOnce( 998 + mockResponse( 999 + { error: "Cannot delete category with boards. Remove all boards first." }, 1000 + false, 1001 + 409 1002 + ) 1003 + ); 1004 + 1005 + const routes = await loadAdminRoutes(); 1006 + const res = await routes.request("/admin/structure/categories/5/delete", { 1007 + method: "POST", 1008 + headers: { cookie: "atbb_session=token" }, 1009 + }); 1010 + 1011 + expect(res.status).toBe(302); 1012 + const location = res.headers.get("location") ?? ""; 1013 + expect(decodeURIComponent(location)).toContain("boards"); 1014 + }); 1015 + 1016 + it("redirects with 'temporarily unavailable' on network failure", async () => { 1017 + setupSession(["space.atbb.permission.manageCategories"]); 1018 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1019 + 1020 + const routes = await loadAdminRoutes(); 1021 + const res = await routes.request("/admin/structure/categories/5/delete", { 1022 + method: "POST", 1023 + headers: { cookie: "atbb_session=token" }, 1024 + }); 1025 + 1026 + expect(res.status).toBe(302); 1027 + const location = res.headers.get("location") ?? ""; 1028 + expect(decodeURIComponent(location)).toContain("unavailable"); 1029 + }); 1030 + }); 1031 + ``` 1032 + 1033 + **Step 2: Run tests to confirm they fail** 1034 + 1035 + ```bash 1036 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \ 1037 + src/routes/__tests__/admin.test.tsx 1038 + ``` 1039 + 1040 + Expected: The new category proxy tests FAIL. Existing tests still pass. 1041 + 1042 + **Step 3: Commit the failing tests** 1043 + 1044 + ```bash 1045 + git add apps/web/src/routes/__tests__/admin.test.tsx 1046 + git commit -m "test(web): add failing tests for category proxy routes (ATB-47)" 1047 + ``` 1048 + 1049 + --- 1050 + 1051 + ## Task 5: Implement category proxy routes 1052 + 1053 + **Files:** 1054 + - Modify: `apps/web/src/routes/admin.tsx` 1055 + 1056 + **Step 1: Add the three category proxy routes** 1057 + 1058 + Inside `createAdminRoutes`, after `GET /admin/structure` and before `return app`: 1059 + 1060 + ```typescript 1061 + // ── POST /admin/structure/categories (create) ──────────────────────────── 1062 + 1063 + app.post("/admin/structure/categories", async (c) => { 1064 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1065 + if (!auth.authenticated) return c.redirect("/login"); 1066 + if (!canManageCategories(auth)) return c.text("Forbidden", 403); 1067 + 1068 + const cookie = c.req.header("cookie") ?? ""; 1069 + 1070 + let body: Record<string, string | File>; 1071 + try { 1072 + body = await c.req.parseBody(); 1073 + } catch (error) { 1074 + if (isProgrammingError(error)) throw error; 1075 + return c.redirect( 1076 + `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302 1077 + ); 1078 + } 1079 + 1080 + const name = typeof body.name === "string" ? body.name.trim() : ""; 1081 + const description = typeof body.description === "string" ? body.description.trim() : undefined; 1082 + const sortOrder = parseSortOrder(body.sortOrder); 1083 + 1084 + if (!name) { 1085 + return c.redirect( 1086 + `/admin/structure?error=${encodeURIComponent("Category name is required.")}`, 302 1087 + ); 1088 + } 1089 + 1090 + let appviewRes: Response; 1091 + try { 1092 + appviewRes = await fetch(`${appviewUrl}/api/admin/categories`, { 1093 + method: "POST", 1094 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1095 + body: JSON.stringify({ name, ...(description && { description }), sortOrder }), 1096 + }); 1097 + } catch (error) { 1098 + if (isProgrammingError(error)) throw error; 1099 + logger.error("Network error creating category", { 1100 + operation: "POST /admin/structure/categories", 1101 + error: error instanceof Error ? error.message : String(error), 1102 + }); 1103 + return c.redirect( 1104 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 1105 + ); 1106 + } 1107 + 1108 + if (appviewRes.ok) return c.redirect("/admin/structure", 302); 1109 + if (appviewRes.status === 401) return c.redirect("/login"); 1110 + 1111 + const errorMsg = await extractAppviewError(appviewRes, "Failed to create category. Please try again."); 1112 + return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302); 1113 + }); 1114 + 1115 + // ── POST /admin/structure/categories/:id/edit ──────────────────────────── 1116 + 1117 + app.post("/admin/structure/categories/:id/edit", async (c) => { 1118 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1119 + if (!auth.authenticated) return c.redirect("/login"); 1120 + if (!canManageCategories(auth)) return c.text("Forbidden", 403); 1121 + 1122 + const id = c.req.param("id"); 1123 + if (!/^\d+$/.test(id)) { 1124 + return c.redirect( 1125 + `/admin/structure?error=${encodeURIComponent("Invalid category ID.")}`, 302 1126 + ); 1127 + } 1128 + 1129 + const cookie = c.req.header("cookie") ?? ""; 1130 + 1131 + let body: Record<string, string | File>; 1132 + try { 1133 + body = await c.req.parseBody(); 1134 + } catch (error) { 1135 + if (isProgrammingError(error)) throw error; 1136 + return c.redirect( 1137 + `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302 1138 + ); 1139 + } 1140 + 1141 + const name = typeof body.name === "string" ? body.name.trim() : ""; 1142 + const description = typeof body.description === "string" ? body.description.trim() : undefined; 1143 + const sortOrder = parseSortOrder(body.sortOrder); 1144 + 1145 + if (!name) { 1146 + return c.redirect( 1147 + `/admin/structure?error=${encodeURIComponent("Category name is required.")}`, 302 1148 + ); 1149 + } 1150 + 1151 + let appviewRes: Response; 1152 + try { 1153 + appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${id}`, { 1154 + method: "PUT", 1155 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1156 + body: JSON.stringify({ name, ...(description !== undefined && { description }), sortOrder }), 1157 + }); 1158 + } catch (error) { 1159 + if (isProgrammingError(error)) throw error; 1160 + logger.error("Network error updating category", { 1161 + operation: "POST /admin/structure/categories/:id/edit", 1162 + id, 1163 + error: error instanceof Error ? error.message : String(error), 1164 + }); 1165 + return c.redirect( 1166 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 1167 + ); 1168 + } 1169 + 1170 + if (appviewRes.ok) return c.redirect("/admin/structure", 302); 1171 + if (appviewRes.status === 401) return c.redirect("/login"); 1172 + 1173 + const errorMsg = await extractAppviewError(appviewRes, "Failed to update category. Please try again."); 1174 + return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302); 1175 + }); 1176 + 1177 + // ── POST /admin/structure/categories/:id/delete ────────────────────────── 1178 + 1179 + app.post("/admin/structure/categories/:id/delete", async (c) => { 1180 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1181 + if (!auth.authenticated) return c.redirect("/login"); 1182 + if (!canManageCategories(auth)) return c.text("Forbidden", 403); 1183 + 1184 + const id = c.req.param("id"); 1185 + if (!/^\d+$/.test(id)) { 1186 + return c.redirect( 1187 + `/admin/structure?error=${encodeURIComponent("Invalid category ID.")}`, 302 1188 + ); 1189 + } 1190 + 1191 + const cookie = c.req.header("cookie") ?? ""; 1192 + 1193 + let appviewRes: Response; 1194 + try { 1195 + appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${id}`, { 1196 + method: "DELETE", 1197 + headers: { Cookie: cookie }, 1198 + }); 1199 + } catch (error) { 1200 + if (isProgrammingError(error)) throw error; 1201 + logger.error("Network error deleting category", { 1202 + operation: "POST /admin/structure/categories/:id/delete", 1203 + id, 1204 + error: error instanceof Error ? error.message : String(error), 1205 + }); 1206 + return c.redirect( 1207 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 1208 + ); 1209 + } 1210 + 1211 + if (appviewRes.ok) return c.redirect("/admin/structure", 302); 1212 + if (appviewRes.status === 401) return c.redirect("/login"); 1213 + 1214 + const errorMsg = await extractAppviewError(appviewRes, "Failed to delete category. Please try again."); 1215 + return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302); 1216 + }); 1217 + ``` 1218 + 1219 + **Step 2: Run the category proxy tests** 1220 + 1221 + ```bash 1222 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \ 1223 + src/routes/__tests__/admin.test.tsx 1224 + ``` 1225 + 1226 + Expected: All category proxy tests PASS. All earlier tests still pass. 1227 + 1228 + **Step 3: Commit** 1229 + 1230 + ```bash 1231 + git add apps/web/src/routes/admin.tsx 1232 + git commit -m "feat(web): add category create/edit/delete proxy routes (ATB-47)" 1233 + ``` 1234 + 1235 + --- 1236 + 1237 + ## Task 6: Failing tests for board proxy routes 1238 + 1239 + **Files:** 1240 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` 1241 + 1242 + **Step 1: Add failing tests** 1243 + 1244 + Add three more describe blocks to `admin.test.tsx`: 1245 + 1246 + ```typescript 1247 + describe("createAdminRoutes — POST /admin/structure/boards", () => { 1248 + // (same beforeEach/afterEach/helpers pattern) 1249 + 1250 + it("redirects to /admin/structure on success and calls AppView POST", async () => { 1251 + setupSession(["space.atbb.permission.manageCategories"]); 1252 + mockFetch.mockResolvedValueOnce( 1253 + mockResponse({ uri: "at://...", cid: "baf..." }, true, 201) 1254 + ); 1255 + 1256 + const routes = await loadAdminRoutes(); 1257 + const res = await routes.request("/admin/structure/boards", { 1258 + method: "POST", 1259 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 1260 + body: new URLSearchParams({ 1261 + name: "New Board", 1262 + description: "", 1263 + sortOrder: "1", 1264 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1265 + }).toString(), 1266 + }); 1267 + 1268 + expect(res.status).toBe(302); 1269 + expect(res.headers.get("location")).toBe("/admin/structure"); 1270 + expect(mockFetch).toHaveBeenCalledWith( 1271 + expect.stringContaining("/api/admin/boards"), 1272 + expect.objectContaining({ method: "POST" }) 1273 + ); 1274 + }); 1275 + 1276 + it("redirects with ?error= and no AppView call when name is empty", async () => { 1277 + setupSession(["space.atbb.permission.manageCategories"]); 1278 + 1279 + const routes = await loadAdminRoutes(); 1280 + const res = await routes.request("/admin/structure/boards", { 1281 + method: "POST", 1282 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 1283 + body: new URLSearchParams({ 1284 + name: "", 1285 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1286 + }).toString(), 1287 + }); 1288 + 1289 + expect(res.status).toBe(302); 1290 + expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/); 1291 + expect(mockFetch).toHaveBeenCalledTimes(2); 1292 + }); 1293 + 1294 + it("redirects with ?error= and no AppView call when categoryUri is missing", async () => { 1295 + setupSession(["space.atbb.permission.manageCategories"]); 1296 + 1297 + const routes = await loadAdminRoutes(); 1298 + const res = await routes.request("/admin/structure/boards", { 1299 + method: "POST", 1300 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 1301 + body: new URLSearchParams({ name: "Test", categoryUri: "" }).toString(), 1302 + }); 1303 + 1304 + expect(res.status).toBe(302); 1305 + expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/); 1306 + expect(mockFetch).toHaveBeenCalledTimes(2); 1307 + }); 1308 + 1309 + it("redirects with ?error= on AppView 409", async () => { 1310 + setupSession(["space.atbb.permission.manageCategories"]); 1311 + mockFetch.mockResolvedValueOnce( 1312 + mockResponse({ error: "Category not found" }, false, 409) 1313 + ); 1314 + 1315 + const routes = await loadAdminRoutes(); 1316 + const res = await routes.request("/admin/structure/boards", { 1317 + method: "POST", 1318 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 1319 + body: new URLSearchParams({ 1320 + name: "Test", 1321 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1322 + }).toString(), 1323 + }); 1324 + 1325 + expect(res.status).toBe(302); 1326 + expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/); 1327 + }); 1328 + }); 1329 + 1330 + describe("createAdminRoutes — POST /admin/structure/boards/:id/edit", () => { 1331 + // (same helpers) 1332 + 1333 + it("redirects to /admin/structure on success and calls AppView PUT", async () => { 1334 + setupSession(["space.atbb.permission.manageCategories"]); 1335 + mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "baf..." }, true, 200)); 1336 + 1337 + const routes = await loadAdminRoutes(); 1338 + const res = await routes.request("/admin/structure/boards/10/edit", { 1339 + method: "POST", 1340 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 1341 + body: new URLSearchParams({ name: "Updated Board", sortOrder: "3" }).toString(), 1342 + }); 1343 + 1344 + expect(res.status).toBe(302); 1345 + expect(res.headers.get("location")).toBe("/admin/structure"); 1346 + expect(mockFetch).toHaveBeenCalledWith( 1347 + expect.stringContaining("/api/admin/boards/10"), 1348 + expect.objectContaining({ method: "PUT" }) 1349 + ); 1350 + }); 1351 + 1352 + it("redirects with ?error= when name is empty", async () => { 1353 + setupSession(["space.atbb.permission.manageCategories"]); 1354 + 1355 + const routes = await loadAdminRoutes(); 1356 + const res = await routes.request("/admin/structure/boards/10/edit", { 1357 + method: "POST", 1358 + headers: { "Content-Type": "application/x-www-form-urlencoded", cookie: "atbb_session=token" }, 1359 + body: new URLSearchParams({ name: "" }).toString(), 1360 + }); 1361 + 1362 + expect(res.status).toBe(302); 1363 + expect(res.headers.get("location")).toMatch(/\/admin\/structure\?error=/); 1364 + expect(mockFetch).toHaveBeenCalledTimes(2); 1365 + }); 1366 + }); 1367 + 1368 + describe("createAdminRoutes — POST /admin/structure/boards/:id/delete", () => { 1369 + // (same helpers) 1370 + 1371 + it("redirects to /admin/structure on success and calls AppView DELETE", async () => { 1372 + setupSession(["space.atbb.permission.manageCategories"]); 1373 + mockFetch.mockResolvedValueOnce(mockResponse({ success: true }, true, 200)); 1374 + 1375 + const routes = await loadAdminRoutes(); 1376 + const res = await routes.request("/admin/structure/boards/10/delete", { 1377 + method: "POST", 1378 + headers: { cookie: "atbb_session=token" }, 1379 + }); 1380 + 1381 + expect(res.status).toBe(302); 1382 + expect(res.headers.get("location")).toBe("/admin/structure"); 1383 + expect(mockFetch).toHaveBeenCalledWith( 1384 + expect.stringContaining("/api/admin/boards/10"), 1385 + expect.objectContaining({ method: "DELETE" }) 1386 + ); 1387 + }); 1388 + 1389 + it("redirects with the AppView's 409 error message when board has posts", async () => { 1390 + setupSession(["space.atbb.permission.manageCategories"]); 1391 + mockFetch.mockResolvedValueOnce( 1392 + mockResponse( 1393 + { error: "Cannot delete board with posts. Remove all posts first." }, 1394 + false, 1395 + 409 1396 + ) 1397 + ); 1398 + 1399 + const routes = await loadAdminRoutes(); 1400 + const res = await routes.request("/admin/structure/boards/10/delete", { 1401 + method: "POST", 1402 + headers: { cookie: "atbb_session=token" }, 1403 + }); 1404 + 1405 + expect(res.status).toBe(302); 1406 + const location = res.headers.get("location") ?? ""; 1407 + expect(decodeURIComponent(location)).toContain("posts"); 1408 + }); 1409 + 1410 + it("redirects with 'temporarily unavailable' on network failure", async () => { 1411 + setupSession(["space.atbb.permission.manageCategories"]); 1412 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1413 + 1414 + const routes = await loadAdminRoutes(); 1415 + const res = await routes.request("/admin/structure/boards/10/delete", { 1416 + method: "POST", 1417 + headers: { cookie: "atbb_session=token" }, 1418 + }); 1419 + 1420 + expect(res.status).toBe(302); 1421 + const location = res.headers.get("location") ?? ""; 1422 + expect(decodeURIComponent(location)).toContain("unavailable"); 1423 + }); 1424 + }); 1425 + ``` 1426 + 1427 + **Step 2: Run to confirm failures** 1428 + 1429 + ```bash 1430 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \ 1431 + src/routes/__tests__/admin.test.tsx 1432 + ``` 1433 + 1434 + Expected: Board proxy tests FAIL. All earlier tests still pass. 1435 + 1436 + **Step 3: Commit failing tests** 1437 + 1438 + ```bash 1439 + git add apps/web/src/routes/__tests__/admin.test.tsx 1440 + git commit -m "test(web): add failing tests for board proxy routes (ATB-47)" 1441 + ``` 1442 + 1443 + --- 1444 + 1445 + ## Task 7: Implement board proxy routes 1446 + 1447 + **Files:** 1448 + - Modify: `apps/web/src/routes/admin.tsx` 1449 + 1450 + **Step 1: Add the three board proxy routes** 1451 + 1452 + Inside `createAdminRoutes`, after the category proxy routes and before `return app`: 1453 + 1454 + ```typescript 1455 + // ── POST /admin/structure/boards (create) ──────────────────────────────── 1456 + 1457 + app.post("/admin/structure/boards", async (c) => { 1458 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1459 + if (!auth.authenticated) return c.redirect("/login"); 1460 + if (!canManageCategories(auth)) return c.text("Forbidden", 403); 1461 + 1462 + const cookie = c.req.header("cookie") ?? ""; 1463 + 1464 + let body: Record<string, string | File>; 1465 + try { 1466 + body = await c.req.parseBody(); 1467 + } catch (error) { 1468 + if (isProgrammingError(error)) throw error; 1469 + return c.redirect( 1470 + `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302 1471 + ); 1472 + } 1473 + 1474 + const name = typeof body.name === "string" ? body.name.trim() : ""; 1475 + const description = typeof body.description === "string" ? body.description.trim() : undefined; 1476 + const sortOrder = parseSortOrder(body.sortOrder); 1477 + const categoryUri = typeof body.categoryUri === "string" ? body.categoryUri.trim() : ""; 1478 + 1479 + if (!name) { 1480 + return c.redirect( 1481 + `/admin/structure?error=${encodeURIComponent("Board name is required.")}`, 302 1482 + ); 1483 + } 1484 + 1485 + if (!categoryUri.startsWith("at://")) { 1486 + return c.redirect( 1487 + `/admin/structure?error=${encodeURIComponent("Invalid category reference. Please try again.")}`, 302 1488 + ); 1489 + } 1490 + 1491 + let appviewRes: Response; 1492 + try { 1493 + appviewRes = await fetch(`${appviewUrl}/api/admin/boards`, { 1494 + method: "POST", 1495 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1496 + body: JSON.stringify({ name, ...(description && { description }), sortOrder, categoryUri }), 1497 + }); 1498 + } catch (error) { 1499 + if (isProgrammingError(error)) throw error; 1500 + logger.error("Network error creating board", { 1501 + operation: "POST /admin/structure/boards", 1502 + error: error instanceof Error ? error.message : String(error), 1503 + }); 1504 + return c.redirect( 1505 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 1506 + ); 1507 + } 1508 + 1509 + if (appviewRes.ok) return c.redirect("/admin/structure", 302); 1510 + if (appviewRes.status === 401) return c.redirect("/login"); 1511 + 1512 + const errorMsg = await extractAppviewError(appviewRes, "Failed to create board. Please try again."); 1513 + return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302); 1514 + }); 1515 + 1516 + // ── POST /admin/structure/boards/:id/edit ──────────────────────────────── 1517 + 1518 + app.post("/admin/structure/boards/:id/edit", async (c) => { 1519 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1520 + if (!auth.authenticated) return c.redirect("/login"); 1521 + if (!canManageCategories(auth)) return c.text("Forbidden", 403); 1522 + 1523 + const id = c.req.param("id"); 1524 + if (!/^\d+$/.test(id)) { 1525 + return c.redirect( 1526 + `/admin/structure?error=${encodeURIComponent("Invalid board ID.")}`, 302 1527 + ); 1528 + } 1529 + 1530 + const cookie = c.req.header("cookie") ?? ""; 1531 + 1532 + let body: Record<string, string | File>; 1533 + try { 1534 + body = await c.req.parseBody(); 1535 + } catch (error) { 1536 + if (isProgrammingError(error)) throw error; 1537 + return c.redirect( 1538 + `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302 1539 + ); 1540 + } 1541 + 1542 + const name = typeof body.name === "string" ? body.name.trim() : ""; 1543 + const description = typeof body.description === "string" ? body.description.trim() : undefined; 1544 + const sortOrder = parseSortOrder(body.sortOrder); 1545 + 1546 + if (!name) { 1547 + return c.redirect( 1548 + `/admin/structure?error=${encodeURIComponent("Board name is required.")}`, 302 1549 + ); 1550 + } 1551 + 1552 + let appviewRes: Response; 1553 + try { 1554 + appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${id}`, { 1555 + method: "PUT", 1556 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1557 + body: JSON.stringify({ name, ...(description !== undefined && { description }), sortOrder }), 1558 + }); 1559 + } catch (error) { 1560 + if (isProgrammingError(error)) throw error; 1561 + logger.error("Network error updating board", { 1562 + operation: "POST /admin/structure/boards/:id/edit", 1563 + id, 1564 + error: error instanceof Error ? error.message : String(error), 1565 + }); 1566 + return c.redirect( 1567 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 1568 + ); 1569 + } 1570 + 1571 + if (appviewRes.ok) return c.redirect("/admin/structure", 302); 1572 + if (appviewRes.status === 401) return c.redirect("/login"); 1573 + 1574 + const errorMsg = await extractAppviewError(appviewRes, "Failed to update board. Please try again."); 1575 + return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302); 1576 + }); 1577 + 1578 + // ── POST /admin/structure/boards/:id/delete ────────────────────────────── 1579 + 1580 + app.post("/admin/structure/boards/:id/delete", async (c) => { 1581 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1582 + if (!auth.authenticated) return c.redirect("/login"); 1583 + if (!canManageCategories(auth)) return c.text("Forbidden", 403); 1584 + 1585 + const id = c.req.param("id"); 1586 + if (!/^\d+$/.test(id)) { 1587 + return c.redirect( 1588 + `/admin/structure?error=${encodeURIComponent("Invalid board ID.")}`, 302 1589 + ); 1590 + } 1591 + 1592 + const cookie = c.req.header("cookie") ?? ""; 1593 + 1594 + let appviewRes: Response; 1595 + try { 1596 + appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${id}`, { 1597 + method: "DELETE", 1598 + headers: { Cookie: cookie }, 1599 + }); 1600 + } catch (error) { 1601 + if (isProgrammingError(error)) throw error; 1602 + logger.error("Network error deleting board", { 1603 + operation: "POST /admin/structure/boards/:id/delete", 1604 + id, 1605 + error: error instanceof Error ? error.message : String(error), 1606 + }); 1607 + return c.redirect( 1608 + `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 1609 + ); 1610 + } 1611 + 1612 + if (appviewRes.ok) return c.redirect("/admin/structure", 302); 1613 + if (appviewRes.status === 401) return c.redirect("/login"); 1614 + 1615 + const errorMsg = await extractAppviewError(appviewRes, "Failed to delete board. Please try again."); 1616 + return c.redirect(`/admin/structure?error=${encodeURIComponent(errorMsg)}`, 302); 1617 + }); 1618 + ``` 1619 + 1620 + **Step 2: Run all tests** 1621 + 1622 + ```bash 1623 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run \ 1624 + src/routes/__tests__/admin.test.tsx 1625 + ``` 1626 + 1627 + Expected: ALL tests PASS. 1628 + 1629 + **Step 3: Commit** 1630 + 1631 + ```bash 1632 + git add apps/web/src/routes/admin.tsx 1633 + git commit -m "feat(web): add board create/edit/delete proxy routes (ATB-47)" 1634 + ``` 1635 + 1636 + --- 1637 + 1638 + ## Task 8: CSS for the structure page 1639 + 1640 + **Files:** 1641 + - Modify: `apps/web/public/static/css/theme.css` 1642 + 1643 + **Step 1: Append structure page styles** 1644 + 1645 + At the end of `theme.css` (after the `/* ─── Admin Member Table ─── */` section), add: 1646 + 1647 + ```css 1648 + /* ─── Admin Structure Page ───────────────────────────────────────────────── */ 1649 + 1650 + .structure-error-banner { 1651 + background-color: var(--color-danger); 1652 + color: var(--color-surface); 1653 + padding: var(--space-sm) var(--space-md); 1654 + font-weight: var(--font-weight-bold); 1655 + margin-bottom: var(--space-md); 1656 + border: var(--border-width) solid var(--color-border); 1657 + } 1658 + 1659 + .structure-page { 1660 + display: flex; 1661 + flex-direction: column; 1662 + gap: var(--space-lg); 1663 + margin-top: var(--space-lg); 1664 + } 1665 + 1666 + .structure-category { 1667 + border: var(--border-width) solid var(--color-border); 1668 + background-color: var(--color-surface); 1669 + box-shadow: var(--card-shadow); 1670 + } 1671 + 1672 + .structure-category__header { 1673 + display: flex; 1674 + align-items: center; 1675 + gap: var(--space-sm); 1676 + padding: var(--space-sm) var(--space-md); 1677 + background-color: var(--color-bg); 1678 + border-bottom: var(--border-width) solid var(--color-border); 1679 + } 1680 + 1681 + .structure-category__name { 1682 + font-weight: var(--font-weight-bold); 1683 + font-size: var(--font-size-lg); 1684 + flex: 1; 1685 + } 1686 + 1687 + .structure-category__meta { 1688 + color: var(--color-text-muted); 1689 + font-size: var(--font-size-sm); 1690 + } 1691 + 1692 + .structure-category__actions { 1693 + display: flex; 1694 + gap: var(--space-xs); 1695 + } 1696 + 1697 + .structure-boards { 1698 + padding: var(--space-sm) var(--space-md) var(--space-sm) calc(var(--space-md) + var(--space-lg)); 1699 + display: flex; 1700 + flex-direction: column; 1701 + gap: var(--space-sm); 1702 + } 1703 + 1704 + .structure-board { 1705 + border: var(--border-width) solid var(--color-border); 1706 + background-color: var(--color-bg); 1707 + } 1708 + 1709 + .structure-board__header { 1710 + display: flex; 1711 + align-items: center; 1712 + gap: var(--space-sm); 1713 + padding: var(--space-xs) var(--space-sm); 1714 + } 1715 + 1716 + .structure-board__name { 1717 + font-weight: var(--font-weight-bold); 1718 + flex: 1; 1719 + } 1720 + 1721 + .structure-board__meta { 1722 + color: var(--color-text-muted); 1723 + font-size: var(--font-size-sm); 1724 + } 1725 + 1726 + .structure-board__actions { 1727 + display: flex; 1728 + gap: var(--space-xs); 1729 + } 1730 + 1731 + .structure-edit-form { 1732 + border-top: var(--border-width) solid var(--color-border); 1733 + } 1734 + 1735 + .structure-edit-form__body { 1736 + display: flex; 1737 + flex-direction: column; 1738 + gap: var(--space-sm); 1739 + padding: var(--space-md); 1740 + } 1741 + 1742 + .structure-add-board { 1743 + border: var(--border-width) dashed var(--color-border); 1744 + margin-top: var(--space-xs); 1745 + } 1746 + 1747 + .structure-add-board__trigger { 1748 + cursor: pointer; 1749 + padding: var(--space-xs) var(--space-sm); 1750 + font-size: var(--font-size-sm); 1751 + color: var(--color-secondary); 1752 + list-style: none; 1753 + } 1754 + 1755 + .structure-add-board__trigger::-webkit-details-marker { 1756 + display: none; 1757 + } 1758 + 1759 + .structure-add-category { 1760 + margin-top: var(--space-md); 1761 + } 1762 + 1763 + .structure-add-category h3 { 1764 + margin-bottom: var(--space-md); 1765 + } 1766 + 1767 + /* ─── Structure Confirm Dialog ─────────────────────────────────────────────── */ 1768 + 1769 + .structure-confirm-dialog { 1770 + border: var(--border-width) solid var(--color-border); 1771 + border-radius: 0; 1772 + padding: var(--space-lg); 1773 + max-width: 420px; 1774 + width: 90vw; 1775 + box-shadow: var(--card-shadow); 1776 + background: var(--color-bg); 1777 + } 1778 + 1779 + .structure-confirm-dialog::backdrop { 1780 + background: rgba(0, 0, 0, 0.5); 1781 + } 1782 + 1783 + .structure-confirm-dialog p { 1784 + margin-top: 0; 1785 + margin-bottom: var(--space-md); 1786 + } 1787 + 1788 + .dialog-actions { 1789 + display: flex; 1790 + gap: var(--space-sm); 1791 + flex-wrap: wrap; 1792 + } 1793 + 1794 + /* ─── Shared Form Utilities ──────────────────────────────────────────────── */ 1795 + 1796 + .form-group { 1797 + display: flex; 1798 + flex-direction: column; 1799 + gap: var(--space-xs); 1800 + } 1801 + 1802 + .form-group label { 1803 + font-weight: var(--font-weight-bold); 1804 + font-size: var(--font-size-sm); 1805 + } 1806 + 1807 + .form-group input[type="text"], 1808 + .form-group input[type="number"], 1809 + .form-group textarea { 1810 + padding: var(--space-xs) var(--space-sm); 1811 + border: var(--input-border); 1812 + border-radius: var(--input-radius); 1813 + font-family: var(--font-body); 1814 + font-size: var(--font-size-base); 1815 + background-color: var(--color-surface); 1816 + width: 100%; 1817 + } 1818 + 1819 + .form-group textarea { 1820 + min-height: 80px; 1821 + resize: vertical; 1822 + } 1823 + ``` 1824 + 1825 + **Step 2: Run the full test suite to confirm no regressions** 1826 + 1827 + ```bash 1828 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm test 1829 + ``` 1830 + 1831 + Expected: All tests pass. 1832 + 1833 + **Step 3: Commit** 1834 + 1835 + ```bash 1836 + git add apps/web/public/static/css/theme.css 1837 + git commit -m "feat(web): add structure page CSS for admin panel (ATB-47)" 1838 + ``` 1839 + 1840 + --- 1841 + 1842 + ## Task 9: Final verification, Linear update, and PR 1843 + 1844 + **Step 1: Run the full test suite** 1845 + 1846 + ```bash 1847 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm test 1848 + ``` 1849 + 1850 + Expected: All tests pass with no failures. 1851 + 1852 + **Step 2: Fix any lint issues** 1853 + 1854 + ```bash 1855 + PATH=$(pwd)/.devenv/profile/bin:$PATH pnpm turbo lint:fix 1856 + ``` 1857 + 1858 + **Step 3: Update Linear** 1859 + 1860 + - Mark ATB-47 status → **Done** 1861 + - Add a comment: "Implemented `/admin/structure` page with full CRUD for categories and boards. Key files: `apps/web/src/routes/admin.tsx` (page + 6 proxy routes), `apps/web/src/routes/__tests__/admin.test.tsx` (tests), `apps/web/public/static/css/theme.css` (structure styles). AppView prerequisite: added `uri` to `serializeCategory` in `apps/appview/src/routes/helpers.ts`." 1862 + 1863 + **Step 4: Create PR** 1864 + 1865 + ```bash 1866 + git push -u origin $(git branch --show-current) 1867 + gh pr create \ 1868 + --title "feat(web): admin structure management UI (ATB-47)" \ 1869 + --body "$(cat <<'EOF' 1870 + ## Summary 1871 + - Adds `/admin/structure` page for CRUD management of forum categories and boards 1872 + - Six web-layer proxy routes translate form POSTs to AppView PUT/DELETE calls 1873 + - Pre-rendered inline edit forms (`<details>`/`<summary>`) and native `<dialog>` delete confirmation 1874 + - Error messages surfaced via `?error=` redirect query param (including 409 referential integrity) 1875 + - AppView prerequisite: added `uri` field to `serializeCategory` response 1876 + 1877 + ## Test plan 1878 + - [ ] Run `pnpm test` — all tests pass 1879 + - [ ] Log in as a user with `manageCategories` permission, visit `/admin/structure` 1880 + - [ ] Create a category → verify it appears after redirect 1881 + - [ ] Edit a category name → verify update appears 1882 + - [ ] Attempt to delete a category that has boards → verify 409 error banner 1883 + - [ ] Create a board under a category → verify it appears nested 1884 + - [ ] Delete an empty board → verify it disappears 1885 + - [ ] Attempt to delete a board with posts → verify 409 error banner 1886 + - [ ] Visit `/admin/structure` without `manageCategories` → verify 403 1887 + EOF 1888 + )" 1889 + ``` 1890 + 1891 + --- 1892 + 1893 + *Plan saved: `docs/plans/2026-03-01-atb-47-admin-structure-ui.md`*