diff --git a/plans/mvp-web-app-launcher/CONTEXT.md b/plans/mvp-web-app-launcher/CONTEXT.md index 6cf8bfb..29740e3 100644 --- a/plans/mvp-web-app-launcher/CONTEXT.md +++ b/plans/mvp-web-app-launcher/CONTEXT.md @@ -6,6 +6,8 @@ Phase 4 (App Registry & Healthcheck) is complete. All app CRUD API routes are im Phase 3 (Authentication System) is complete. The full local authentication flow is implemented: login, registration, logout, and JWT token refresh. `hooks.server.ts` validates access tokens on every request, injects `event.locals.user`/`session`, and silently rotates expired tokens via refresh tokens. Protected routes redirect to `/login`; guest-accessible board routes are exempt. Login and registration pages use Superforms + Zod with inline validation errors. Registration respects the `SystemSettings.registrationEnabled` toggle. Reusable middleware helpers (`requireAuth`, `requireAdmin`, `requireRole`) are available for downstream phases. The root layout injects user session into all page data. The root page redirects to the default board or login. `jwt.ts` and `password.ts` are thin re-exports from `authService` (no duplication). Build does not pass yet (Big Bang strategy — expected). +Phase 5 (Board, Section & Widget System) is complete. All 20 tasks implemented: 5 API route files for board/section/widget CRUD (`/api/boards`, `/api/boards/[id]`, `/api/boards/[id]/sections`, `/api/boards/[id]/sections/[sid]`, `/api/boards/[id]/sections/[sid]/widgets`), 3 page routes for board list (`/boards`), board view (`/boards/[boardId]`), and board editor (`/boards/[boardId]/edit`), plus 9 Svelte components across board/section/widget directories. Board list API filters by permissions: admins see all, regular users see boards where they have VIEW+ permission via `permissionService.checkPermission()`, guests see only `isGuestAccessible` boards. Board view loads the full hierarchy (board -> sections -> widgets -> app -> latest status) via `boardService.findBoardById`. The board editor uses SvelteKit form actions (updateBoard, addSection/updateSection/deleteSection, addWidget/deleteWidget) with `use:enhance` for progressive enhancement. Section collapse uses Svelte's built-in `slide` transition. Widget grid is responsive CSS grid (2 cols mobile, 3 tablet, 4 desktop). `AppWidget` reuses `AppHealthBadge` for status display. + ## Temporary Workarounds - Permission model uses polymorphic pattern (entityType/targetType strings) without FK relations to avoid SQLite dual-FK constraint issues. Queries are done manually in `permissionService.ts`. diff --git a/plans/mvp-web-app-launcher/PLAN.md b/plans/mvp-web-app-launcher/PLAN.md index 0b6fb36..0ce254f 100644 --- a/plans/mvp-web-app-launcher/PLAN.md +++ b/plans/mvp-web-app-launcher/PLAN.md @@ -44,7 +44,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi | Phase 2: Database & Services | backend | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 3: Authentication | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 4: App & Healthcheck | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | -| Phase 5: Board & Widgets | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 5: Board & Widgets | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 6: Admin Panel | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 7: UI Polish | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 8: Integration & Deploy | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/mvp-web-app-launcher/phase-5-board-widgets.md b/plans/mvp-web-app-launcher/phase-5-board-widgets.md index 7eb5304..2a80b32 100644 --- a/plans/mvp-web-app-launcher/phase-5-board-widgets.md +++ b/plans/mvp-web-app-launcher/phase-5-board-widgets.md @@ -1,6 +1,6 @@ # Phase 5: Board, Section & Widget System -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -9,26 +9,26 @@ Build the board/section/widget system — the core UI of the dashboard. Implemen ## Tasks -- [ ] Task 1: Create `src/routes/api/boards/+server.ts` — GET (list, filtered by permissions), POST -- [ ] Task 2: Create `src/routes/api/boards/[id]/+server.ts` — GET, PATCH, DELETE -- [ ] Task 3: Create `src/routes/api/boards/[id]/sections/+server.ts` — GET, POST -- [ ] Task 4: Create `src/routes/api/boards/[id]/sections/[sid]/+server.ts` — GET, PATCH, DELETE -- [ ] Task 5: Create `src/routes/api/boards/[id]/sections/[sid]/widgets/+server.ts` — GET, POST, PATCH, DELETE -- [ ] Task 6: Create `src/routes/boards/+page.server.ts` — load board list -- [ ] Task 7: Create `src/routes/boards/+page.svelte` — board list page -- [ ] Task 8: Create `src/routes/boards/[boardId]/+page.server.ts` — load board with sections, widgets, app data -- [ ] Task 9: Create `src/routes/boards/[boardId]/+page.svelte` — board view page -- [ ] Task 10: Create `src/routes/boards/[boardId]/edit/+page.server.ts` — board editor data + actions -- [ ] Task 11: Create `src/routes/boards/[boardId]/edit/+page.svelte` — board editor page -- [ ] Task 12: Create `src/lib/components/board/Board.svelte` — board container -- [ ] Task 13: Create `src/lib/components/board/BoardHeader.svelte` — board title, description, actions -- [ ] Task 14: Create `src/lib/components/board/BoardCard.svelte` — board card for list view -- [ ] Task 15: Create `src/lib/components/section/Section.svelte` — section container -- [ ] Task 16: Create `src/lib/components/section/SectionHeader.svelte` — section title with collapse toggle -- [ ] Task 17: Create `src/lib/components/section/SectionCollapsible.svelte` — collapsible wrapper -- [ ] Task 18: Create `src/lib/components/widget/AppWidget.svelte` — app widget displaying icon, name, status -- [ ] Task 19: Create `src/lib/components/widget/WidgetContainer.svelte` — generic widget wrapper -- [ ] Task 20: Create `src/lib/components/widget/WidgetGrid.svelte` — responsive grid layout for widgets +- [x] Task 1: Create `src/routes/api/boards/+server.ts` — GET (list, filtered by permissions), POST +- [x] Task 2: Create `src/routes/api/boards/[id]/+server.ts` — GET, PATCH, DELETE +- [x] Task 3: Create `src/routes/api/boards/[id]/sections/+server.ts` — GET, POST +- [x] Task 4: Create `src/routes/api/boards/[id]/sections/[sid]/+server.ts` — GET, PATCH, DELETE +- [x] Task 5: Create `src/routes/api/boards/[id]/sections/[sid]/widgets/+server.ts` — GET, POST, PATCH, DELETE +- [x] Task 6: Create `src/routes/boards/+page.server.ts` — load board list +- [x] Task 7: Create `src/routes/boards/+page.svelte` — board list page +- [x] Task 8: Create `src/routes/boards/[boardId]/+page.server.ts` — load board with sections, widgets, app data +- [x] Task 9: Create `src/routes/boards/[boardId]/+page.svelte` — board view page +- [x] Task 10: Create `src/routes/boards/[boardId]/edit/+page.server.ts` — board editor data + actions +- [x] Task 11: Create `src/routes/boards/[boardId]/edit/+page.svelte` — board editor page +- [x] Task 12: Create `src/lib/components/board/Board.svelte` — board container +- [x] Task 13: Create `src/lib/components/board/BoardHeader.svelte` — board title, description, actions +- [x] Task 14: Create `src/lib/components/board/BoardCard.svelte` — board card for list view +- [x] Task 15: Create `src/lib/components/section/Section.svelte` — section container +- [x] Task 16: Create `src/lib/components/section/SectionHeader.svelte` — section title with collapse toggle +- [x] Task 17: Create `src/lib/components/section/SectionCollapsible.svelte` — collapsible wrapper +- [x] Task 18: Create `src/lib/components/widget/AppWidget.svelte` — app widget displaying icon, name, status +- [x] Task 19: Create `src/lib/components/widget/WidgetContainer.svelte` — generic widget wrapper +- [x] Task 20: Create `src/lib/components/widget/WidgetGrid.svelte` — responsive grid layout for widgets ## Files to Modify/Create - `src/routes/api/boards/+server.ts` @@ -61,14 +61,25 @@ Build the board/section/widget system — the core UI of the dashboard. Implemen - Section collapse uses Svelte `slide` transition - Board editor is a form-based editor (drag-and-drop is post-MVP Phase 2) - Permission filtering on board list uses permissionService -- ⚠️ Big Bang: functional but minimally styled until Phase 7 +- Big Bang: functional but minimally styled until Phase 7 ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows project conventions -- [ ] No unintended side effects +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next Phase - + +Phase 5 is complete. All board, section, and widget CRUD APIs are implemented with permission-based filtering (admin sees all, regular users see permitted boards, guests see guest-accessible boards only). The board view page loads the full board hierarchy (board -> sections -> widgets -> app + status) via `boardService.findBoardById`. The board editor provides form-based management of board properties, sections (add/delete), and widgets (add app widgets from a dropdown, remove). All Svelte components use runes mode and follow existing patterns: +- `Board.svelte` renders sections in order +- `Section.svelte` uses `SectionHeader` (chevron toggle) + `SectionCollapsible` (Svelte `slide` transition) +- `WidgetGrid.svelte` uses a responsive CSS grid (2/3/4 cols) +- `AppWidget.svelte` displays app icon, name, and health status badge (reuses `AppHealthBadge`) +- `BoardCard.svelte` shows board summary with section count, default/guest badges + +Key files for Phase 6 (Admin Panel): +- Board API routes at `/api/boards/**` are ready for admin operations +- Permission checking via `permissionService.checkPermission()` is integrated into all write operations +- Board editor at `/boards/[boardId]/edit` is functional for admin use diff --git a/src/lib/components/board/Board.svelte b/src/lib/components/board/Board.svelte new file mode 100644 index 0000000..691021b --- /dev/null +++ b/src/lib/components/board/Board.svelte @@ -0,0 +1,45 @@ + + +
+ {#if sections.length === 0} +
+

This board has no sections yet.

+
+ {:else} + {#each sections as section (section.id)} +
+ {/each} + {/if} +
diff --git a/src/lib/components/board/BoardCard.svelte b/src/lib/components/board/BoardCard.svelte new file mode 100644 index 0000000..305a5a0 --- /dev/null +++ b/src/lib/components/board/BoardCard.svelte @@ -0,0 +1,57 @@ + + + +
+ {#if board.icon} + {board.icon} + {:else} + + B + + {/if} +
+
+

+ {board.name} +

+ {#if board.isDefault} + + Default + + {/if} + {#if board.isGuestAccessible} + + Guest + + {/if} +
+ {#if board.description} +

{board.description}

+ {/if} +

+ {sectionCount} section{sectionCount === 1 ? '' : 's'} +

+
+
+
diff --git a/src/lib/components/board/BoardHeader.svelte b/src/lib/components/board/BoardHeader.svelte new file mode 100644 index 0000000..262dbb9 --- /dev/null +++ b/src/lib/components/board/BoardHeader.svelte @@ -0,0 +1,42 @@ + + +
+
+ {#if icon} + {icon} + {/if} +
+

{name}

+ {#if description} +

{description}

+ {/if} +
+
+ +
+ + All Boards + + {#if canEdit} + + Edit + + {/if} +
+
diff --git a/src/lib/components/section/Section.svelte b/src/lib/components/section/Section.svelte new file mode 100644 index 0000000..84ba5a0 --- /dev/null +++ b/src/lib/components/section/Section.svelte @@ -0,0 +1,54 @@ + + +
+ (expanded = !expanded)} + /> + + +
+ +
+
+
diff --git a/src/lib/components/section/SectionCollapsible.svelte b/src/lib/components/section/SectionCollapsible.svelte new file mode 100644 index 0000000..7afae57 --- /dev/null +++ b/src/lib/components/section/SectionCollapsible.svelte @@ -0,0 +1,17 @@ + + +{#if expanded} +
+ {@render children()} +
+{/if} diff --git a/src/lib/components/section/SectionHeader.svelte b/src/lib/components/section/SectionHeader.svelte new file mode 100644 index 0000000..7b18ad6 --- /dev/null +++ b/src/lib/components/section/SectionHeader.svelte @@ -0,0 +1,36 @@ + + + diff --git a/src/lib/components/widget/AppWidget.svelte b/src/lib/components/widget/AppWidget.svelte new file mode 100644 index 0000000..9659d3a --- /dev/null +++ b/src/lib/components/widget/AppWidget.svelte @@ -0,0 +1,68 @@ + + + + +
+ {#if app.iconType === 'emoji' && app.icon} + {app.icon} + {:else if iconSrc} + {app.name} icon + {:else} + + {app.name.charAt(0).toUpperCase()} + + {/if} +
+ + + + {app.name} + + + + +
diff --git a/src/lib/components/widget/WidgetContainer.svelte b/src/lib/components/widget/WidgetContainer.svelte new file mode 100644 index 0000000..ec93071 --- /dev/null +++ b/src/lib/components/widget/WidgetContainer.svelte @@ -0,0 +1,13 @@ + + +
+ {@render children()} +
diff --git a/src/lib/components/widget/WidgetGrid.svelte b/src/lib/components/widget/WidgetGrid.svelte new file mode 100644 index 0000000..d36fb1f --- /dev/null +++ b/src/lib/components/widget/WidgetGrid.svelte @@ -0,0 +1,45 @@ + + +{#if widgets.length === 0} +

No widgets in this section.

+{:else} +
+ {#each widgets as widget (widget.id)} + + {#if widget.type === 'app' && widget.app} + + {:else} +
+ {widget.type} widget +
+ {/if} +
+ {/each} +
+{/if} diff --git a/src/routes/api/boards/+server.ts b/src/routes/api/boards/+server.ts new file mode 100644 index 0000000..9a23545 --- /dev/null +++ b/src/routes/api/boards/+server.ts @@ -0,0 +1,98 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import * as boardService from '$lib/server/services/boardService.js'; +import * as permissionService from '$lib/server/services/permissionService.js'; +import { createBoardSchema } from '$lib/utils/validators.js'; +import { success, error } from '$lib/server/utils/response.js'; +import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js'; +import { prisma } from '$lib/server/prisma.js'; + +/** + * GET /api/boards — List boards filtered by permissions. + * - Admin: sees all boards + * - Regular user: sees boards where they have VIEW+ permission + * - Guest (no user): sees only guest-accessible boards + */ +export const GET: RequestHandler = async (event) => { + const user = event.locals.user; + + try { + if (!user) { + // Guest: only guest-accessible boards + const boards = await prisma.board.findMany({ + where: { isGuestAccessible: true }, + orderBy: { createdAt: 'asc' }, + include: { _count: { select: { sections: true } } } + }); + return json(success(boards)); + } + + if (user.role === UserRole.ADMIN) { + // Admin: all boards + const boards = await boardService.findAllBoards(); + return json(success(boards)); + } + + // Regular user: boards with VIEW+ permission (user-level or group-level) + const allBoards = await boardService.findAllBoards(); + const accessibleBoards = []; + + for (const board of allBoards) { + // Guest-accessible boards are visible to all authenticated users too + if (board.isGuestAccessible) { + accessibleBoards.push(board); + continue; + } + + const result = await permissionService.checkPermission( + EntityType.BOARD, + board.id, + user.id, + PermissionLevel.VIEW + ); + + if (result.hasPermission) { + accessibleBoards.push(board); + } + } + + return json(success(accessibleBoards)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch boards'; + return json(error(message), { status: 500 }); + } +}; + +/** + * POST /api/boards — Create a new board (auth required). + */ +export const POST: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + const parsed = createBoardSchema.safeParse(body); + if (!parsed.success) { + const messages = parsed.error.errors.map((e) => e.message).join(', '); + return json(error(messages), { status: 400 }); + } + + try { + const board = await boardService.createBoard({ + ...parsed.data, + createdById: user.id + }); + return json(success(board), { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create board'; + return json(error(message), { status: 500 }); + } +}; diff --git a/src/routes/api/boards/[id]/+server.ts b/src/routes/api/boards/[id]/+server.ts new file mode 100644 index 0000000..3c1b79f --- /dev/null +++ b/src/routes/api/boards/[id]/+server.ts @@ -0,0 +1,127 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import * as boardService from '$lib/server/services/boardService.js'; +import * as permissionService from '$lib/server/services/permissionService.js'; +import { updateBoardSchema } from '$lib/utils/validators.js'; +import { success, error } from '$lib/server/utils/response.js'; +import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js'; +import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js'; + +/** + * GET /api/boards/:id — Get a single board with sections and widgets. + */ +export const GET: RequestHandler = async (event) => { + const { id } = event.params; + const user = event.locals.user; + + try { + // Check access: guest can only see guest-accessible boards + if (!user) { + const isGuest = await isBoardGuestAccessible(id); + if (!isGuest) { + return json(error('Authentication required'), { status: 401 }); + } + } else if (user.role !== UserRole.ADMIN) { + const result = await permissionService.checkPermission( + EntityType.BOARD, + id, + user.id, + PermissionLevel.VIEW + ); + if (!result.hasPermission) { + const isGuest = await isBoardGuestAccessible(id); + if (!isGuest) { + return json(error('Insufficient permissions'), { status: 403 }); + } + } + } + + const board = await boardService.findBoardById(id); + return json(success(board)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Board not found'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; + +/** + * PATCH /api/boards/:id — Update a board (auth required). + */ +export const PATCH: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + const { id } = event.params; + + // Check edit permission + if (user.role !== UserRole.ADMIN) { + const result = await permissionService.checkPermission( + EntityType.BOARD, + id, + user.id, + PermissionLevel.EDIT + ); + if (!result.hasPermission) { + return json(error('Insufficient permissions'), { status: 403 }); + } + } + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + const parsed = updateBoardSchema.safeParse(body); + if (!parsed.success) { + const messages = parsed.error.errors.map((e) => e.message).join(', '); + return json(error(messages), { status: 400 }); + } + + try { + const board = await boardService.updateBoard(id, parsed.data); + return json(success(board)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to update board'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; + +/** + * DELETE /api/boards/:id — Delete a board (auth required). + */ +export const DELETE: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + const { id } = event.params; + + // Only admin or users with ADMIN permission on the board can delete + if (user.role !== UserRole.ADMIN) { + const result = await permissionService.checkPermission( + EntityType.BOARD, + id, + user.id, + PermissionLevel.ADMIN + ); + if (!result.hasPermission) { + return json(error('Insufficient permissions'), { status: 403 }); + } + } + + try { + await boardService.removeBoard(id); + return json(success(null)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete board'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; diff --git a/src/routes/api/boards/[id]/sections/+server.ts b/src/routes/api/boards/[id]/sections/+server.ts new file mode 100644 index 0000000..7e326e0 --- /dev/null +++ b/src/routes/api/boards/[id]/sections/+server.ts @@ -0,0 +1,71 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import * as boardService from '$lib/server/services/boardService.js'; +import { createSectionSchema } from '$lib/utils/validators.js'; +import { success, error } from '$lib/server/utils/response.js'; +import { prisma } from '$lib/server/prisma.js'; + +/** + * GET /api/boards/:id/sections — List sections for a board. + */ +export const GET: RequestHandler = async (event) => { + const { id } = event.params; + + try { + // Verify board exists + await boardService.findBoardById(id); + + const sections = await prisma.section.findMany({ + where: { boardId: id }, + orderBy: { order: 'asc' }, + include: { + widgets: { + orderBy: { order: 'asc' } + } + } + }); + return json(success(sections)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch sections'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; + +/** + * POST /api/boards/:id/sections — Create a section in a board (auth required). + */ +export const POST: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + const { id } = event.params; + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + // Inject the boardId from the URL param + const parsed = createSectionSchema.safeParse({ ...body as object, boardId: id }); + if (!parsed.success) { + const messages = parsed.error.errors.map((e) => e.message).join(', '); + return json(error(messages), { status: 400 }); + } + + try { + // Verify board exists + await boardService.findBoardById(id); + + const section = await boardService.createSection(parsed.data); + return json(success(section), { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create section'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; diff --git a/src/routes/api/boards/[id]/sections/[sid]/+server.ts b/src/routes/api/boards/[id]/sections/[sid]/+server.ts new file mode 100644 index 0000000..8294e2e --- /dev/null +++ b/src/routes/api/boards/[id]/sections/[sid]/+server.ts @@ -0,0 +1,76 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import * as boardService from '$lib/server/services/boardService.js'; +import { updateSectionSchema } from '$lib/utils/validators.js'; +import { success, error } from '$lib/server/utils/response.js'; + +/** + * GET /api/boards/:id/sections/:sid — Get a single section. + */ +export const GET: RequestHandler = async (event) => { + const { sid } = event.params; + + try { + const section = await boardService.findSectionById(sid); + return json(success(section)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Section not found'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; + +/** + * PATCH /api/boards/:id/sections/:sid — Update a section (auth required). + */ +export const PATCH: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + const { sid } = event.params; + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + const parsed = updateSectionSchema.safeParse(body); + if (!parsed.success) { + const messages = parsed.error.errors.map((e) => e.message).join(', '); + return json(error(messages), { status: 400 }); + } + + try { + const section = await boardService.updateSection(sid, parsed.data); + return json(success(section)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to update section'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; + +/** + * DELETE /api/boards/:id/sections/:sid — Delete a section (auth required). + */ +export const DELETE: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + const { sid } = event.params; + + try { + await boardService.removeSection(sid); + return json(success(null)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete section'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; diff --git a/src/routes/api/boards/[id]/sections/[sid]/widgets/+server.ts b/src/routes/api/boards/[id]/sections/[sid]/widgets/+server.ts new file mode 100644 index 0000000..7551a90 --- /dev/null +++ b/src/routes/api/boards/[id]/sections/[sid]/widgets/+server.ts @@ -0,0 +1,137 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import * as boardService from '$lib/server/services/boardService.js'; +import { createWidgetSchema, updateWidgetSchema } from '$lib/utils/validators.js'; +import { success, error } from '$lib/server/utils/response.js'; +import { prisma } from '$lib/server/prisma.js'; + +/** + * GET /api/boards/:id/sections/:sid/widgets — List widgets in a section. + */ +export const GET: RequestHandler = async (event) => { + const { sid } = event.params; + + try { + // Verify section exists + await boardService.findSectionById(sid); + + const widgets = await prisma.widget.findMany({ + where: { sectionId: sid }, + orderBy: { order: 'asc' }, + include: { + app: { + include: { + statuses: { + orderBy: { checkedAt: 'desc' }, + take: 1 + } + } + } + } + }); + return json(success(widgets)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch widgets'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; + +/** + * POST /api/boards/:id/sections/:sid/widgets — Create a widget (auth required). + */ +export const POST: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + const { sid } = event.params; + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + // Inject sectionId from URL param + const parsed = createWidgetSchema.safeParse({ ...body as object, sectionId: sid }); + if (!parsed.success) { + const messages = parsed.error.errors.map((e) => e.message).join(', '); + return json(error(messages), { status: 400 }); + } + + try { + // Verify section exists + await boardService.findSectionById(sid); + + const widget = await boardService.createWidget(parsed.data); + return json(success(widget), { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create widget'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; + +/** + * PATCH /api/boards/:id/sections/:sid/widgets — Update a widget by widgetId in body (auth required). + */ +export const PATCH: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + const { widgetId, ...updateData } = body as { widgetId?: string; [key: string]: unknown }; + if (!widgetId) { + return json(error('widgetId is required'), { status: 400 }); + } + + const parsed = updateWidgetSchema.safeParse(updateData); + if (!parsed.success) { + const messages = parsed.error.errors.map((e) => e.message).join(', '); + return json(error(messages), { status: 400 }); + } + + try { + const widget = await boardService.updateWidget(widgetId, parsed.data); + return json(success(widget)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to update widget'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; + +/** + * DELETE /api/boards/:id/sections/:sid/widgets — Delete a widget by widgetId in query (auth required). + */ +export const DELETE: RequestHandler = async (event) => { + const user = event.locals.user; + if (!user) { + return json(error('Authentication required'), { status: 401 }); + } + + const widgetId = event.url.searchParams.get('widgetId'); + if (!widgetId) { + return json(error('widgetId query parameter is required'), { status: 400 }); + } + + try { + await boardService.removeWidget(widgetId); + return json(success(null)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete widget'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; diff --git a/src/routes/boards/+page.server.ts b/src/routes/boards/+page.server.ts new file mode 100644 index 0000000..a588275 --- /dev/null +++ b/src/routes/boards/+page.server.ts @@ -0,0 +1,48 @@ +import type { PageServerLoad } from './$types.js'; +import * as boardService from '$lib/server/services/boardService.js'; +import * as permissionService from '$lib/server/services/permissionService.js'; +import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js'; +import { prisma } from '$lib/server/prisma.js'; + +export const load: PageServerLoad = async ({ locals }) => { + const user = locals.user; + + if (!user) { + // Guest: only guest-accessible boards + const boards = await prisma.board.findMany({ + where: { isGuestAccessible: true }, + orderBy: { createdAt: 'asc' }, + include: { _count: { select: { sections: true } } } + }); + return { boards, isGuest: true }; + } + + if (user.role === UserRole.ADMIN) { + const boards = await boardService.findAllBoards(); + return { boards, isGuest: false }; + } + + // Regular user: filter by permissions + const allBoards = await boardService.findAllBoards(); + const accessibleBoards = []; + + for (const board of allBoards) { + if (board.isGuestAccessible) { + accessibleBoards.push(board); + continue; + } + + const result = await permissionService.checkPermission( + EntityType.BOARD, + board.id, + user.id, + PermissionLevel.VIEW + ); + + if (result.hasPermission) { + accessibleBoards.push(board); + } + } + + return { boards: accessibleBoards, isGuest: false }; +}; diff --git a/src/routes/boards/+page.svelte b/src/routes/boards/+page.svelte new file mode 100644 index 0000000..d2c732a --- /dev/null +++ b/src/routes/boards/+page.svelte @@ -0,0 +1,45 @@ + + + + Boards + + +
+
+
+

Boards

+

+ {data.boards.length} board{data.boards.length === 1 ? '' : 's'} available +

+
+ + {#if !data.isGuest && data.user?.role === 'admin'} + + New Board + + {/if} +
+ + {#if data.boards.length === 0} +
+

No boards available.

+ {#if data.isGuest} +

Sign in to see more boards.

+ {/if} +
+ {:else} +
+ {#each data.boards as board (board.id)} + + {/each} +
+ {/if} +
diff --git a/src/routes/boards/[boardId]/+page.server.ts b/src/routes/boards/[boardId]/+page.server.ts new file mode 100644 index 0000000..c93ecba --- /dev/null +++ b/src/routes/boards/[boardId]/+page.server.ts @@ -0,0 +1,61 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types.js'; +import * as boardService from '$lib/server/services/boardService.js'; +import * as permissionService from '$lib/server/services/permissionService.js'; +import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js'; +import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js'; + +export const load: PageServerLoad = async ({ params, locals }) => { + const { boardId } = params; + const user = locals.user; + + // Permission check + if (!user) { + const isGuest = await isBoardGuestAccessible(boardId); + if (!isGuest) { + throw error(401, { message: 'Authentication required' }); + } + } else if (user.role !== UserRole.ADMIN) { + const result = await permissionService.checkPermission( + EntityType.BOARD, + boardId, + user.id, + PermissionLevel.VIEW + ); + if (!result.hasPermission) { + const isGuest = await isBoardGuestAccessible(boardId); + if (!isGuest) { + throw error(403, { message: 'Insufficient permissions' }); + } + } + } + + try { + // findBoardById includes sections -> widgets -> app -> statuses + const board = await boardService.findBoardById(boardId); + + // Determine if user can edit this board + let canEdit = false; + if (user) { + if (user.role === UserRole.ADMIN) { + canEdit = true; + } else { + const editResult = await permissionService.checkPermission( + EntityType.BOARD, + boardId, + user.id, + PermissionLevel.EDIT + ); + canEdit = editResult.hasPermission; + } + } + + return { board, canEdit }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Board not found'; + if (message.includes('not found')) { + throw error(404, { message: 'Board not found' }); + } + throw error(500, { message }); + } +}; diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte new file mode 100644 index 0000000..adb4979 --- /dev/null +++ b/src/routes/boards/[boardId]/+page.svelte @@ -0,0 +1,23 @@ + + + + {data.board.name} + + +
+ + + +
diff --git a/src/routes/boards/[boardId]/edit/+page.server.ts b/src/routes/boards/[boardId]/edit/+page.server.ts new file mode 100644 index 0000000..c9f3bd9 --- /dev/null +++ b/src/routes/boards/[boardId]/edit/+page.server.ts @@ -0,0 +1,198 @@ +import { error, redirect } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types.js'; +import * as boardService from '$lib/server/services/boardService.js'; +import * as appService from '$lib/server/services/appService.js'; +import * as permissionService from '$lib/server/services/permissionService.js'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js'; +import { + updateBoardSchema, + createSectionSchema, + updateSectionSchema, + createWidgetSchema +} from '$lib/utils/validators.js'; + +export const load: PageServerLoad = async (event) => { + const user = requireAuth(event); + const { boardId } = event.params; + + // Check edit permission + if (user.role !== UserRole.ADMIN) { + const result = await permissionService.checkPermission( + EntityType.BOARD, + boardId, + user.id, + PermissionLevel.EDIT + ); + if (!result.hasPermission) { + throw error(403, { message: 'Insufficient permissions' }); + } + } + + try { + const board = await boardService.findBoardById(boardId); + const apps = await appService.findAll(); + + return { board, apps }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Board not found'; + if (message.includes('not found')) { + throw error(404, { message: 'Board not found' }); + } + throw error(500, { message }); + } +}; + +export const actions: Actions = { + updateBoard: async (event) => { + requireAuth(event); + const { boardId } = event.params; + const formData = await event.request.formData(); + + const data = { + name: formData.get('name') as string | undefined, + icon: formData.get('icon') as string | undefined, + description: formData.get('description') as string | undefined, + isDefault: formData.get('isDefault') === 'on', + isGuestAccessible: formData.get('isGuestAccessible') === 'on' + }; + + const parsed = updateBoardSchema.safeParse(data); + if (!parsed.success) { + return { success: false, error: parsed.error.errors.map((e) => e.message).join(', ') }; + } + + try { + await boardService.updateBoard(boardId, parsed.data); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Failed to update board' + }; + } + }, + + addSection: async (event) => { + requireAuth(event); + const { boardId } = event.params; + const formData = await event.request.formData(); + + const data = { + boardId, + title: formData.get('title') as string, + icon: (formData.get('icon') as string) || undefined, + isExpandedByDefault: formData.get('isExpandedByDefault') !== 'off' + }; + + const parsed = createSectionSchema.safeParse(data); + if (!parsed.success) { + return { success: false, error: parsed.error.errors.map((e) => e.message).join(', ') }; + } + + try { + await boardService.createSection(parsed.data); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Failed to add section' + }; + } + }, + + updateSection: async (event) => { + requireAuth(event); + const formData = await event.request.formData(); + const sectionId = formData.get('sectionId') as string; + + const data = { + title: (formData.get('title') as string) || undefined, + icon: formData.get('icon') as string | undefined, + order: formData.get('order') ? Number(formData.get('order')) : undefined, + isExpandedByDefault: + formData.get('isExpandedByDefault') !== null + ? formData.get('isExpandedByDefault') !== 'off' + : undefined + }; + + const parsed = updateSectionSchema.safeParse(data); + if (!parsed.success) { + return { success: false, error: parsed.error.errors.map((e) => e.message).join(', ') }; + } + + try { + await boardService.updateSection(sectionId, parsed.data); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Failed to update section' + }; + } + }, + + deleteSection: async (event) => { + requireAuth(event); + const formData = await event.request.formData(); + const sectionId = formData.get('sectionId') as string; + + try { + await boardService.removeSection(sectionId); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Failed to delete section' + }; + } + }, + + addWidget: async (event) => { + requireAuth(event); + const formData = await event.request.formData(); + const sectionId = formData.get('sectionId') as string; + const appId = (formData.get('appId') as string) || undefined; + const type = (formData.get('type') as string) || 'app'; + + const config = appId ? JSON.stringify({ appId }) : '{}'; + + const data = { + sectionId, + type, + config, + appId + }; + + const parsed = createWidgetSchema.safeParse(data); + if (!parsed.success) { + return { success: false, error: parsed.error.errors.map((e) => e.message).join(', ') }; + } + + try { + await boardService.createWidget(parsed.data); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Failed to add widget' + }; + } + }, + + deleteWidget: async (event) => { + requireAuth(event); + const formData = await event.request.formData(); + const widgetId = formData.get('widgetId') as string; + + try { + await boardService.removeWidget(widgetId); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Failed to delete widget' + }; + } + } +}; diff --git a/src/routes/boards/[boardId]/edit/+page.svelte b/src/routes/boards/[boardId]/edit/+page.svelte new file mode 100644 index 0000000..cdeebac --- /dev/null +++ b/src/routes/boards/[boardId]/edit/+page.svelte @@ -0,0 +1,263 @@ + + + + Edit: {data.board.name} + + +
+
+

Edit Board

+ + Back to Board + +
+ + +
+

Board Properties

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + +
+
+

Sections

+ +
+ + {#if showAddSection} +
+
{ + return async ({ update }) => { + await update(); + showAddSection = false; + }; + }} + > +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ {/if} + + {#if data.board.sections.length === 0} +
+

No sections yet. Add one to get started.

+
+ {:else} +
+ {#each data.board.sections as section (section.id)} +
+
+
+ {section.title} + Order: {section.order} + {#if section.icon} + ({section.icon}) + {/if} +
+
+ +
+ + +
+
+
+ + {#if addWidgetSectionId === section.id} +
+
{ + return async ({ update }) => { + await update(); + addWidgetSectionId = null; + }; + }} + > + + +
+ + +
+
+ +
+
+
+ {/if} + + + {#if section.widgets.length === 0} +

No widgets in this section.

+ {:else} +
+ {#each section.widgets as widget (widget.id)} +
+
+ {widget.type} + {#if widget.app} + {widget.app.name} + ({widget.app.url}) + {:else} + Widget #{widget.order} + {/if} +
+
+ + +
+
+ {/each} +
+ {/if} +
+ {/each} +
+ {/if} +
+