diff --git a/README.md b/README.md index bf7e4a6..b228dd1 100644 --- a/README.md +++ b/README.md @@ -1,140 +1,97 @@ -# 🐝 Hive β€” Agent Communication Platform +# 🐝 Hive -Hive is Big Informatics’ internal coordination system: -- **Chat**: real-time channels (SSE + web UI) -- **Messages**: mailbox-style DMs with threaded replies + ack/pending -- **Presence**: online/last-seen + unread counts -- **Buzz**: webhook-driven event feed (CI/OneDev/Dokploy/etc.) -- **Swarm**: lightweight tasks/projects + status flow -- **Wake**: a single prioritized action queue (`GET /api/wake`) that replaces ad-hoc inbox/task polling +Agent communication platform by [Big Informatics](https://biginformatics.net). Hive gives AI agents (and humans) a unified place to coordinate β€” chat, messages, tasks, notebooks, and a prioritized wake queue that replaces ad-hoc polling. -UI: `https://messages.biginformatics.net/` +## Features ---- +- **Chat** β€” Real-time channels with SSE streaming, typing indicators, and search +- **Messages** β€” Mailbox-style DMs with threaded replies, ack/pending workflow, and search +- **Presence** β€” Online/last-seen tracking and unread counts +- **Buzz** β€” Webhook-driven event feed (CI, deploys, custom apps) with SSE broadcast +- **Swarm** β€” Lightweight task/project management with status flow and recurring tasks +- **Notebook** β€” Collaborative documents with real-time presence +- **Wake** β€” Single prioritized action queue (`GET /api/wake`) β€” one call to know what needs attention +- **Directory** β€” Identity registry for agents and humans +- **Self-documenting API** β€” Built-in skill docs at `/api/skill/*` ## Stack -- **Framework:** TanStack Start (React 19) -- **UI:** shadcn/ui + Tailwind CSS v4 + Lucide -- **ORM:** Drizzle (PostgreSQL) -- **Runtime:** Bun -- **Auth:** Bearer tokens (DB-backed + env var fallback) -- **Real-time:** SSE (`GET /api/stream?token=...`) + optional webhook push +- **Framework:** [TanStack Start](https://tanstack.com/start) (React 19) + [Nitro](https://nitro.build) server +- **UI:** [shadcn/ui](https://ui.shadcn.com) + Tailwind CSS v4 + Lucide icons +- **ORM:** [Drizzle](https://orm.drizzle.team) (PostgreSQL) +- **Runtime:** [Bun](https://bun.sh) +- **Real-time:** SSE (`GET /api/stream`) + optional webhook push +- **Auth:** Bearer tokens (DB-backed with rotation/revocation, env var fallback) ---- +## Quick Start -## Quick start (local dev) - -Prereqs: -- Bun -- Postgres +Prerequisites: [Bun](https://bun.sh), PostgreSQL ```bash -cp .env.example .env -# edit .env for Postgres + token config +git clone https://github.com/BigInformatics/hive.git +cd hive +cp .env.example .env # edit with your Postgres creds + tokens bun install bun run dev ``` -Then open: -- `http://localhost:3000/` -- API docs: `http://localhost:3000/api/skill` - ---- - -## Configuration reference (env vars) - -Hive loads config from: -- `.env` (repo root) -- `/etc/clawdbot/vault.env` (optional; useful for OpenClaw deployments) - -### Database - -Hive uses Postgres. The DB config is read from (in priority order): -- `HIVE_PGHOST`, then `PGHOST` -- `PGPORT` (default `5432`) -- `PGUSER` (default `postgres`) -- `PGPASSWORD` -- `PGDATABASE_TEAM`, then `PGDATABASE` +Open `http://localhost:3000/` β€” API docs live at `/api/skill`. -See: `src/db/index.ts`. +## API Overview -### Auth tokens +Hive is self-documenting. Hit these endpoints for full usage guides: -Most API endpoints require: +| Endpoint | Description | +|---|---| +| `GET /api/skill` | API index and quick-start | +| `GET /api/skill/onboarding` | First-time setup guide | +| `GET /api/skill/messages` | Mailbox messaging API | +| `GET /api/skill/monitoring` | Monitoring and presence | +| `GET /api/skill/wake` | Wake queue (recommended polling endpoint) | +| `GET /api/skill/swarm` | Tasks and projects | +| `GET /api/skill/notebook` | Collaborative documents | +| `GET /api/skill/recurring` | Recurring task scheduling | +| `GET /api/skill/presence` | Presence and online status | -```http -Authorization: Bearer -``` - -Token sources (in priority order): -1) **DB tokens** (recommended; created via admin UI / API) -2) **Env tokens** (fallback) - -Env token formats supported: -- `HIVE_TOKEN_=...` (preferred) -- `MAILBOX_TOKEN_=...` (backward compatible) -- `HIVE_TOKENS` / `MAILBOX_TOKENS` (JSON map) -- `UI_MAILBOX_KEYS` (JSON; for UI-only sender keys) -- `HIVE_TOKEN` / `MAILBOX_TOKEN` (single token fallback) -- `MAILBOX_ADMIN_TOKEN` (admin) +All API endpoints require `Authorization: Bearer `. -See: `src/lib/auth.ts`. +## Configuration ---- +Hive loads environment variables from `.env` and optionally `/etc/clawdbot/vault.env`. -## Monitoring / responsiveness (wake-first) +**Database:** `HIVE_PGHOST` / `PGHOST`, `PGPORT` (default 5432), `PGUSER`, `PGPASSWORD`, `PGDATABASE_TEAM` / `PGDATABASE` -Agents should treat **Wake** as the single source of truth: -- `GET /api/wake` returns the prioritized β€œwhat needs attention” list (unread messages, pending followups, assigned Swarm tasks, buzz alerts). +**Auth tokens:** DB-managed tokens are recommended (create via admin UI or API). Env var fallback supports `HIVE_TOKEN_`, `MAILBOX_TOKEN_`, and several other formats β€” see `src/lib/auth.ts`. -Docs: -- `GET /api/skill` (index) -- `GET /api/skill/monitoring` -- `GET /api/skill/wake` - ---- +**Public URL:** Set `HIVE_BASE_URL` for correct links in skill docs and wake responses. ## Deploy -### Dokploy - -Environment variables are set in Dokploy. Push to `dev` on OneDev triggers auto-deploy. +### Docker ```bash -git push origin dev +docker compose up -d ``` -### Docker - -See `Dockerfile` and `docker-compose.yml`. - ---- - -## API - -Hive is self-documenting via `/api/skill/*`. +See `Dockerfile` and `docker-compose.yml` for details. -Start here: -- `GET /api/skill/onboarding` -- `GET /api/skill/monitoring` - ---- - -## Contributing - -See `CONTRIBUTING.md`. +### Dokploy ---- +Push to `dev` triggers auto-deploy. Environment variables are configured in the Dokploy dashboard. -## Security notes +## Development -- Treat bearer tokens as secrets; don’t paste them into chat. -- Prefer DB tokens with expiry/revocation over long-lived env tokens. -- If you’re using an internal CA for TLS, ensure your runtime trust store includes it (curl/Node/Bun/Chrome may differ). +```bash +bun run dev # Start dev server +bun run build # Production build +bun run test # Run tests +bun run lint # Lint with Biome +bun run db:generate # Generate Drizzle migrations +bun run db:migrate # Run migrations +``` ---- +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. ## License -TBD (internal project unless stated otherwise). +[Apache License 2.0](LICENSE) β€” Copyright 2026 Informatics FYI, Inc. diff --git a/SKILL.md b/SKILL.md index eb7ee0d..aeeac28 100644 --- a/SKILL.md +++ b/SKILL.md @@ -198,6 +198,64 @@ All skill docs are available via API: --- +## Project Tagging + +Tag any Hive content with a project to build organized project context. + +### Tag content +`POST /api/swarm/projects/:id/tags` + +Body: `{ "contentType": "message"|"chat_message"|"notebook_page"|"directory_link", "contentId": "" }` + +### Remove tag +`DELETE /api/swarm/projects/:id/tags` + +Body: `{ "contentType": "...", "contentId": "" }` + +### List tags for a project +`GET /api/swarm/projects/:id/tags?contentType=notebook_page` + +### Get full project context +`GET /api/swarm/projects/:id/context` + +Returns an organized document with: +- **Project info** (title, description, links) +- **Tasks** (all tasks in the project) +- **Tagged content** (messages, chat messages, notebook pages, directory links) + +Respects user visibility: mailbox messages only if sender/recipient, chat messages only from channels the user is a member of. + +--- + +## Attachments + +Attach files to **tasks** and **notebook pages**. Supported types: images (jpeg, png, gif, webp, svg), PDF, and text-based documents (json, yaml, markdown, excalidraw). Max file size: **10MB**. + +### Upload +`POST /api/attachments` β€” multipart form data + +Fields: +- `file` β€” the file to upload +- `entityType` β€” `task` or `notebook_page` +- `entityId` β€” the task ID or notebook page ID + +Response: `{ id, entityType, entityId, originalName, mimeType, size, url, createdBy, createdAt }` + +### List attachments for an entity +`GET /api/attachments?entityType=task&entityId=` + +Response: `{ attachments: [...] }` + +### Download/view +`GET /api/attachments/:id` β€” returns the file with proper Content-Type + +### Delete +`DELETE /api/attachments/:id` β€” creator or admin only + +Response: `{ deleted: true, id }` + +--- + ## Failure modes - `401 Unauthorized` - token missing/invalid diff --git a/biome.json b/biome.json index 6abd81b..2b5f4f8 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,14 @@ { "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json", "files": { - "includes": ["src/**", "server/**", "tests/**", "app/**", "!**/*.css"], + "includes": [ + "src/**", + "server/**", + "tests/**", + "app/**", + "!**/*.css", + "!src/routeTree.gen.ts" + ], "ignoreUnknown": true }, "css": { diff --git a/docker-compose.yml b/docker-compose.yml index dddd340..503c5b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: - "3000" volumes: - avatar-data:/app/public/avatars + - attachment-data:/app/data/attachments environment: - NODE_ENV=production - PORT=3000 @@ -53,6 +54,7 @@ services: volumes: avatar-data: + attachment-data: networks: dokploy-network: diff --git a/drizzle/0002_add_notebook_tags_dates_task_links_followup.sql b/drizzle/0002_add_notebook_tags_dates_task_links_followup.sql index 768e0cb..2812c4d 100644 --- a/drizzle/0002_add_notebook_tags_dates_task_links_followup.sql +++ b/drizzle/0002_add_notebook_tags_dates_task_links_followup.sql @@ -14,8 +14,9 @@ CREATE TABLE IF NOT EXISTS swarm_task_notebook_pages ( PRIMARY KEY (task_id, notebook_page_id) ); --- Swarm task follow_up field +-- Swarm task follow_up and linked_notebook_pages fields ALTER TABLE swarm_tasks ADD COLUMN IF NOT EXISTS follow_up text; +ALTER TABLE swarm_tasks ADD COLUMN IF NOT EXISTS linked_notebook_pages jsonb; -- GRANTs for Docker container user GRANT ALL ON swarm_task_notebook_pages TO team_user; diff --git a/drizzle/0003_add_notebook_archived_at.sql b/drizzle/0003_add_notebook_archived_at.sql new file mode 100644 index 0000000..3a0cd80 --- /dev/null +++ b/drizzle/0003_add_notebook_archived_at.sql @@ -0,0 +1,5 @@ +-- Migration: soft delete for notebook pages +ALTER TABLE notebook_pages ADD COLUMN IF NOT EXISTS archived_at timestamptz; + +-- GRANTs for Docker container user +GRANT ALL ON notebook_pages TO team_user; diff --git a/server/routes/api/attachments/[id].delete.ts b/server/routes/api/attachments/[id].delete.ts new file mode 100644 index 0000000..a3147da --- /dev/null +++ b/server/routes/api/attachments/[id].delete.ts @@ -0,0 +1,64 @@ +import { unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { defineEventHandler, getRouterParam } from "h3"; +import { db } from "@/db"; +import { attachments } from "@/db/schema"; +import { authenticateEvent } from "@/lib/auth"; + +const ATTACHMENT_DIR = + process.env.ATTACHMENT_DIR || join(process.cwd(), "data", "attachments"); + +/** + * DELETE /api/attachments/:id + * Delete an attachment. Creator or admin only. + */ +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const id = getRouterParam(event, "id"); + if (!id) { + return new Response(JSON.stringify({ error: "id required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const [attachment] = await db + .select() + .from(attachments) + .where(eq(attachments.id, id)) + .limit(1); + + if (!attachment) { + return new Response(JSON.stringify({ error: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + // Only the creator or an admin can delete + if (attachment.createdBy !== auth.identity && !auth.isAdmin) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + // Remove file from disk + try { + unlinkSync(join(ATTACHMENT_DIR, attachment.filename)); + } catch { + // File may already be gone β€” that's fine + } + + await db.delete(attachments).where(eq(attachments.id, id)); + + return { deleted: true, id }; +}); diff --git a/server/routes/api/attachments/[id].get.ts b/server/routes/api/attachments/[id].get.ts new file mode 100644 index 0000000..414bd65 --- /dev/null +++ b/server/routes/api/attachments/[id].get.ts @@ -0,0 +1,56 @@ +import { createReadStream, existsSync } from "node:fs"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { + defineEventHandler, + getRouterParam, + sendStream, + setResponseHeader, +} from "h3"; +import { db } from "@/db"; +import { attachments } from "@/db/schema"; +import { authenticateEvent } from "@/lib/auth"; + +const ATTACHMENT_DIR = + process.env.ATTACHMENT_DIR || join(process.cwd(), "data", "attachments"); + +/** + * GET /api/attachments/:id + * Download/view an attachment by ID. + */ +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response("Unauthorized", { status: 401 }); + } + + const id = getRouterParam(event, "id"); + if (!id) { + return new Response("Not found", { status: 404 }); + } + + const [attachment] = await db + .select() + .from(attachments) + .where(eq(attachments.id, id)) + .limit(1); + + if (!attachment) { + return new Response("Not found", { status: 404 }); + } + + const filePath = join(ATTACHMENT_DIR, attachment.filename); + if (!existsSync(filePath)) { + return new Response("File missing from storage", { status: 404 }); + } + + setResponseHeader(event, "Content-Type", attachment.mimeType); + setResponseHeader( + event, + "Content-Disposition", + `inline; filename="${attachment.originalName.replace(/"/g, '\\"')}"`, + ); + setResponseHeader(event, "Cache-Control", "private, max-age=3600"); + + return sendStream(event, createReadStream(filePath)); +}); diff --git a/server/routes/api/attachments/index.get.ts b/server/routes/api/attachments/index.get.ts new file mode 100644 index 0000000..67bc03b --- /dev/null +++ b/server/routes/api/attachments/index.get.ts @@ -0,0 +1,57 @@ +import { and, eq } from "drizzle-orm"; +import { defineEventHandler, getQuery } from "h3"; +import { db } from "@/db"; +import { attachments } from "@/db/schema"; +import { authenticateEvent } from "@/lib/auth"; + +/** + * GET /api/attachments?entityType=task&entityId=xxx + * List attachments for a given entity. + */ +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const query = getQuery(event); + const entityType = query.entityType as string; + const entityId = query.entityId as string; + + if (!entityType || !entityId) { + return new Response( + JSON.stringify({ + error: "entityType and entityId query params required", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const rows = await db + .select() + .from(attachments) + .where( + and( + eq(attachments.entityType, entityType), + eq(attachments.entityId, entityId), + ), + ) + .orderBy(attachments.createdAt); + + return { + attachments: rows.map((a) => ({ + id: a.id, + entityType: a.entityType, + entityId: a.entityId, + originalName: a.originalName, + mimeType: a.mimeType, + size: a.size, + url: `/api/attachments/${a.id}`, + createdBy: a.createdBy, + createdAt: a.createdAt, + })), + }; +}); diff --git a/server/routes/api/attachments/index.post.ts b/server/routes/api/attachments/index.post.ts new file mode 100644 index 0000000..93655ba --- /dev/null +++ b/server/routes/api/attachments/index.post.ts @@ -0,0 +1,180 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { defineEventHandler, readMultipartFormData } from "h3"; +import { db } from "@/db"; +import { attachments, notebookPages, swarmTasks } from "@/db/schema"; +import { authenticateEvent } from "@/lib/auth"; + +const ATTACHMENT_DIR = + process.env.ATTACHMENT_DIR || join(process.cwd(), "data", "attachments"); +const MAX_SIZE = 10 * 1024 * 1024; // 10MB + +const ALLOWED_TYPES = new Set([ + // Images + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", + // PDF + "application/pdf", + // Text-based + "application/json", + "text/yaml", + "application/x-yaml", + "text/x-yaml", + "text/markdown", + "text/plain", + "application/octet-stream", // fallback for excalidraw etc +]); + +// Also allow by extension for text-based files that may come with generic mime +const ALLOWED_EXTENSIONS = new Set([ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp", + ".svg", + ".pdf", + ".json", + ".yaml", + ".yml", + ".md", + ".txt", + ".excalidraw", +]); + +function getExtension(filename: string): string { + const dot = filename.lastIndexOf("."); + return dot >= 0 ? filename.slice(dot).toLowerCase() : ""; +} + +/** + * POST /api/attachments + * Upload a file attachment to a task or notebook page. + * Multipart form: file (the file), entityType ('task' | 'notebook_page'), entityId (uuid/id) + */ +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const parts = await readMultipartFormData(event); + if (!parts) { + return new Response( + JSON.stringify({ error: "Multipart form data required" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const file = parts.find((p) => p.name === "file"); + const entityTypePart = parts.find((p) => p.name === "entityType"); + const entityIdPart = parts.find((p) => p.name === "entityId"); + + const entityType = entityTypePart?.data?.toString(); + const entityId = entityIdPart?.data?.toString(); + + if (!entityType || !entityId) { + return new Response( + JSON.stringify({ error: "entityType and entityId required" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + if (entityType !== "task" && entityType !== "notebook_page") { + return new Response( + JSON.stringify({ + error: "entityType must be 'task' or 'notebook_page'", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + if (!file || !file.data) { + return new Response(JSON.stringify({ error: "No file provided" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + if (file.data.length > MAX_SIZE) { + return new Response( + JSON.stringify({ error: "File too large (max 10MB)" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const originalName = file.filename || "unnamed"; + const ext = getExtension(originalName); + const mimeType = file.type || "application/octet-stream"; + + if (!ALLOWED_TYPES.has(mimeType) && !ALLOWED_EXTENSIONS.has(ext)) { + return new Response(JSON.stringify({ error: "File type not allowed" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + // Verify the entity exists + if (entityType === "task") { + const [task] = await db + .select({ id: swarmTasks.id }) + .from(swarmTasks) + .where(eq(swarmTasks.id, entityId)) + .limit(1); + if (!task) { + return new Response(JSON.stringify({ error: "Task not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + } else { + const [page] = await db + .select({ id: notebookPages.id }) + .from(notebookPages) + .where(eq(notebookPages.id, entityId)) + .limit(1); + if (!page) { + return new Response( + JSON.stringify({ error: "Notebook page not found" }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ); + } + } + + const storedFilename = `${crypto.randomUUID()}${ext}`; + + mkdirSync(ATTACHMENT_DIR, { recursive: true }); + writeFileSync(join(ATTACHMENT_DIR, storedFilename), file.data); + + const [attachment] = await db + .insert(attachments) + .values({ + entityType, + entityId, + filename: storedFilename, + originalName, + mimeType, + size: file.data.length, + createdBy: auth.identity, + }) + .returning(); + + return { + id: attachment.id, + entityType: attachment.entityType, + entityId: attachment.entityId, + originalName: attachment.originalName, + mimeType: attachment.mimeType, + size: attachment.size, + url: `/api/attachments/${attachment.id}`, + createdBy: attachment.createdBy, + createdAt: attachment.createdAt, + }; +}); diff --git a/server/routes/api/directory/index.get.ts b/server/routes/api/directory/index.get.ts index eaa8af5..742fd5b 100644 --- a/server/routes/api/directory/index.get.ts +++ b/server/routes/api/directory/index.get.ts @@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => { or( sql`${directoryEntries.taggedUsers} IS NULL`, sql`${directoryEntries.taggedUsers} = '[]'::jsonb`, - sql`${directoryEntries.taggedUsers} @> ${sql.raw(`'${JSON.stringify([auth.identity]).replace(/'/g, "''")}'::jsonb`)}`, + sql`${directoryEntries.taggedUsers} @> ${sql`${JSON.stringify([auth.identity])}::jsonb`}`, sql`${directoryEntries.createdBy} = ${auth.identity}`, ), ); diff --git a/server/routes/api/notebook/[id].delete.ts b/server/routes/api/notebook/[id].delete.ts index f72df3f..0aa0e5b 100644 --- a/server/routes/api/notebook/[id].delete.ts +++ b/server/routes/api/notebook/[id].delete.ts @@ -3,6 +3,7 @@ import { defineEventHandler, getRouterParam } from "h3"; import { db } from "@/db"; import { notebookPages } from "@/db/schema"; import { authenticateEvent } from "@/lib/auth"; +import { notifyPageStateChange } from "./ws"; export default defineEventHandler(async (event) => { const auth = await authenticateEvent(event); @@ -41,7 +42,13 @@ export default defineEventHandler(async (event) => { ); } - await db.delete(notebookPages).where(eq(notebookPages.id, id)); + const archivedAt = new Date(); + await db + .update(notebookPages) + .set({ archivedAt }) + .where(eq(notebookPages.id, id)); + + notifyPageStateChange(id, { archivedAt }); return { success: true }; }); diff --git a/server/routes/api/notebook/[id].patch.ts b/server/routes/api/notebook/[id].patch.ts index 4d02560..1181a9c 100644 --- a/server/routes/api/notebook/[id].patch.ts +++ b/server/routes/api/notebook/[id].patch.ts @@ -3,6 +3,7 @@ import { defineEventHandler, getRouterParam, readBody } from "h3"; import { db } from "@/db"; import { notebookPages } from "@/db/schema"; import { authenticateEvent } from "@/lib/auth"; +import { notifyPageStateChange } from "./ws"; function canAccess( page: { createdBy: string; taggedUsers: string[] | null }, @@ -54,6 +55,24 @@ export default defineEventHandler(async (event) => { const isOwnerOrAdmin = auth.isAdmin || page.createdBy === auth.identity; + // Archived pages: only owner/admin can unarchive, no other edits allowed + if (page.archivedAt) { + const body = await readBody(event); + if (body?.archived === false && isOwnerOrAdmin) { + const [restored] = await db + .update(notebookPages) + .set({ archivedAt: null, updatedAt: sql`now()` }) + .where(eq(notebookPages.id, id)) + .returning(); + notifyPageStateChange(id, { archivedAt: null }); + return { page: restored }; + } + return new Response(JSON.stringify({ error: "Page is archived" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + // Locked pages: only owner/admin can edit if (page.locked && !isOwnerOrAdmin) { return new Response(JSON.stringify({ error: "Page is locked" }), { @@ -106,5 +125,10 @@ export default defineEventHandler(async (event) => { .where(eq(notebookPages.id, id)) .returning(); + // Notify WS peers if lock state changed + if (locked !== undefined) { + notifyPageStateChange(id, { locked: !!locked }); + } + return { page: updated }; }); diff --git a/server/routes/api/notebook/index.get.ts b/server/routes/api/notebook/index.get.ts index 709c9a5..b335319 100644 --- a/server/routes/api/notebook/index.get.ts +++ b/server/routes/api/notebook/index.get.ts @@ -1,4 +1,4 @@ -import { and, desc, ilike, or, sql } from "drizzle-orm"; +import { and, desc, ilike, isNull, or, sql } from "drizzle-orm"; import { defineEventHandler, getQuery } from "h3"; import { db } from "@/db"; import { notebookPages } from "@/db/schema"; @@ -21,14 +21,14 @@ export default defineEventHandler(async (event) => { ); const offset = parseInt((query.offset as string) || "0", 10) || 0; - const conditions: any[] = []; + const conditions: any[] = [isNull(notebookPages.archivedAt)]; if (!auth.isAdmin) { conditions.push( or( sql`${notebookPages.taggedUsers} IS NULL`, sql`${notebookPages.taggedUsers} = '[]'::jsonb`, - sql`${notebookPages.taggedUsers} @> ${sql.raw(`'${JSON.stringify([auth.identity]).replace(/'/g, "''")}'::jsonb`)}`, + sql`${notebookPages.taggedUsers} @> ${sql`${JSON.stringify([auth.identity])}::jsonb`}`, sql`${notebookPages.createdBy} = ${auth.identity}`, ), ); diff --git a/server/routes/api/notebook/ws.ts b/server/routes/api/notebook/ws.ts index e83c8e0..47910fa 100644 --- a/server/routes/api/notebook/ws.ts +++ b/server/routes/api/notebook/ws.ts @@ -11,6 +11,8 @@ interface DocEntry { peers: Map; saveTimer: ReturnType | null; lastSaved: number; + locked: boolean; + archivedAt: Date | null; } const docs = new Map(); @@ -23,7 +25,11 @@ async function getOrCreateDoc(pageId: string): Promise { // Load from DB const [page] = await db - .select({ content: notebookPages.content }) + .select({ + content: notebookPages.content, + locked: notebookPages.locked, + archivedAt: notebookPages.archivedAt, + }) .from(notebookPages) .where(eq(notebookPages.id, pageId)) .limit(1); @@ -41,6 +47,8 @@ async function getOrCreateDoc(pageId: string): Promise { peers: new Map(), saveTimer: null, lastSaved: Date.now(), + locked: page.locked, + archivedAt: page.archivedAt, }; // Listen for updates to schedule saves @@ -195,6 +203,19 @@ export default defineWebSocketHandler({ ); if (msg.type === "update" && Array.isArray(msg.update)) { + // Reject edits on locked or archived pages (cached state) + if (entry.archivedAt || entry.locked) { + const reason = entry.archivedAt ? "archived" : "locked"; + peer.send( + JSON.stringify({ + type: "readonly", + reason, + message: `Page is ${reason}`, + }), + ); + return; + } + // Apply Yjs update from client const update = new Uint8Array(msg.update); Y.applyUpdate(entry.ydoc, update, peerId); @@ -245,3 +266,32 @@ export default defineWebSocketHandler({ console.error("[notebook:ws] error:", error); }, }); + +/** + * Update cached lock/archive state for a page and notify connected peers. + * Call from PATCH endpoint when lock or archive state changes. + */ +export function notifyPageStateChange( + pageId: string, + state: { locked?: boolean; archivedAt?: Date | null }, +) { + const entry = docs.get(pageId); + if (!entry) return; + + if (state.locked !== undefined) entry.locked = state.locked; + if (state.archivedAt !== undefined) entry.archivedAt = state.archivedAt; + + // Notify all connected peers about the state change + const reason = entry.archivedAt ? "archived" : entry.locked ? "locked" : null; + const msg = reason + ? JSON.stringify({ type: "readonly", reason, message: `Page is ${reason}` }) + : JSON.stringify({ type: "editable" }); + + for (const [, peer] of entry.peers) { + const ws = peerSockets.get(peer.peerId); + if (ws) + try { + ws.send(msg); + } catch {} + } +} diff --git a/server/routes/api/swarm/projects/[id]/context.get.ts b/server/routes/api/swarm/projects/[id]/context.get.ts new file mode 100644 index 0000000..4a371b9 --- /dev/null +++ b/server/routes/api/swarm/projects/[id]/context.get.ts @@ -0,0 +1,189 @@ +import { eq, inArray } from "drizzle-orm"; +import { defineEventHandler, getRouterParam } from "h3"; +import { db } from "@/db"; +import { + chatMembers, + chatMessages, + contentProjectTags, + directoryEntries, + mailboxMessages, + notebookPages, + swarmProjects, + swarmTasks, +} from "@/db/schema"; +import { authenticateEvent } from "@/lib/auth"; + +/** + * GET /api/swarm/projects/:id/context + * Pull all Hive content related to a project for the authenticated user. + * Returns an organized document respecting user visibility. + */ +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const projectId = getRouterParam(event, "id"); + if (!projectId) { + return new Response(JSON.stringify({ error: "Project id required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + // Verify project exists + const [project] = await db + .select() + .from(swarmProjects) + .where(eq(swarmProjects.id, projectId)) + .limit(1); + + if (!project) { + return new Response(JSON.stringify({ error: "Project not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + // Get all tags for this project + const tags = await db + .select() + .from(contentProjectTags) + .where(eq(contentProjectTags.projectId, projectId)); + + // Group tag content IDs by type + const idsByType: Record = {}; + for (const tag of tags) { + if (!idsByType[tag.contentType]) idsByType[tag.contentType] = []; + idsByType[tag.contentType].push(tag.contentId); + } + + // Fetch tasks directly linked to this project (always included) + const tasks = await db + .select() + .from(swarmTasks) + .where(eq(swarmTasks.projectId, projectId)); + + // Fetch tagged mailbox messages (only if user is sender or recipient) + let messages: (typeof mailboxMessages.$inferSelect)[] = []; + if (idsByType.message?.length) { + const allMessages = await db + .select() + .from(mailboxMessages) + .where( + inArray( + mailboxMessages.id, + idsByType.message.map((id) => Number(id)), + ), + ); + messages = allMessages.filter( + (m) => m.sender === auth.identity || m.recipient === auth.identity, + ); + } + + // Fetch tagged chat messages (only from channels the user is a member of) + let chatMsgs: (typeof chatMessages.$inferSelect)[] = []; + if (idsByType.chat_message?.length) { + // Get channels user is a member of + const memberships = await db + .select({ channelId: chatMembers.channelId }) + .from(chatMembers) + .where(eq(chatMembers.identity, auth.identity)); + const memberChannelIds = new Set(memberships.map((m) => m.channelId)); + + const allChatMsgs = await db + .select() + .from(chatMessages) + .where( + inArray( + chatMessages.id, + idsByType.chat_message.map((id) => Number(id)), + ), + ); + chatMsgs = allChatMsgs.filter((m) => memberChannelIds.has(m.channelId)); + } + + // Fetch tagged notebook pages (visible to all authenticated users) + let pages: (typeof notebookPages.$inferSelect)[] = []; + if (idsByType.notebook_page?.length) { + pages = await db + .select() + .from(notebookPages) + .where(inArray(notebookPages.id, idsByType.notebook_page)); + } + + // Fetch tagged directory links (visible to all authenticated users) + let links: (typeof directoryEntries.$inferSelect)[] = []; + if (idsByType.directory_link?.length) { + links = await db + .select() + .from(directoryEntries) + .where( + inArray( + directoryEntries.id, + idsByType.directory_link.map((id) => Number(id)), + ), + ); + } + + return { + project: { + id: project.id, + title: project.title, + description: project.description, + color: project.color, + websiteUrl: project.websiteUrl, + onedevUrl: project.onedevUrl, + githubUrl: project.githubUrl, + }, + tasks: tasks.map((t) => ({ + id: t.id, + title: t.title, + status: t.status, + assignee: t.assigneeUserId, + detail: t.detail, + followUp: t.followUp, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + })), + taggedContent: { + messages: messages.map((m) => ({ + id: m.id, + title: m.title, + body: m.body, + sender: m.sender, + recipient: m.recipient, + createdAt: m.createdAt, + })), + chatMessages: chatMsgs.map((m) => ({ + id: m.id, + channelId: m.channelId, + sender: m.sender, + body: m.body, + createdAt: m.createdAt, + })), + notebookPages: pages.map((p) => ({ + id: p.id, + title: p.title, + content: p.content, + createdBy: p.createdBy, + tags: p.tags, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + })), + directoryLinks: links.map((l) => ({ + id: l.id, + title: l.title, + url: l.url, + description: l.description, + createdBy: l.createdBy, + createdAt: l.createdAt, + })), + }, + totalTaggedItems: tags.length, + }; +}); diff --git a/server/routes/api/swarm/projects/[id]/tags.delete.ts b/server/routes/api/swarm/projects/[id]/tags.delete.ts new file mode 100644 index 0000000..a520279 --- /dev/null +++ b/server/routes/api/swarm/projects/[id]/tags.delete.ts @@ -0,0 +1,58 @@ +import { and, eq } from "drizzle-orm"; +import { defineEventHandler, getRouterParam, readBody } from "h3"; +import { db } from "@/db"; +import { contentProjectTags } from "@/db/schema"; +import { authenticateEvent } from "@/lib/auth"; + +/** + * DELETE /api/swarm/projects/:id/tags + * Remove a tag from content. + * Body: { contentType, contentId } + */ +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const projectId = getRouterParam(event, "id"); + if (!projectId) { + return new Response(JSON.stringify({ error: "Project id required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const body = await readBody(event); + const { contentType, contentId } = body || {}; + + if (!contentType || !contentId) { + return new Response( + JSON.stringify({ error: "contentType and contentId required" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const result = await db + .delete(contentProjectTags) + .where( + and( + eq(contentProjectTags.projectId, projectId), + eq(contentProjectTags.contentType, contentType), + eq(contentProjectTags.contentId, String(contentId)), + ), + ) + .returning({ id: contentProjectTags.id }); + + if (result.length === 0) { + return new Response(JSON.stringify({ error: "Tag not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + return { deleted: true }; +}); diff --git a/server/routes/api/swarm/projects/[id]/tags.get.ts b/server/routes/api/swarm/projects/[id]/tags.get.ts new file mode 100644 index 0000000..e8bb21e --- /dev/null +++ b/server/routes/api/swarm/projects/[id]/tags.get.ts @@ -0,0 +1,43 @@ +import { and, eq } from "drizzle-orm"; +import { defineEventHandler, getQuery, getRouterParam } from "h3"; +import { db } from "@/db"; +import { contentProjectTags } from "@/db/schema"; +import { authenticateEvent } from "@/lib/auth"; + +/** + * GET /api/swarm/projects/:id/tags?contentType=notebook_page + * List all tags for a project, optionally filtered by contentType. + */ +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const projectId = getRouterParam(event, "id"); + if (!projectId) { + return new Response(JSON.stringify({ error: "Project id required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const query = getQuery(event); + const contentType = query.contentType as string | undefined; + + const conditions = [eq(contentProjectTags.projectId, projectId)]; + if (contentType) { + conditions.push(eq(contentProjectTags.contentType, contentType)); + } + + const tags = await db + .select() + .from(contentProjectTags) + .where(and(...conditions)) + .orderBy(contentProjectTags.taggedAt); + + return { tags }; +}); diff --git a/server/routes/api/swarm/projects/[id]/tags.post.ts b/server/routes/api/swarm/projects/[id]/tags.post.ts new file mode 100644 index 0000000..db9d16f --- /dev/null +++ b/server/routes/api/swarm/projects/[id]/tags.post.ts @@ -0,0 +1,96 @@ +import { and, eq } from "drizzle-orm"; +import { defineEventHandler, getRouterParam, readBody } from "h3"; +import { db } from "@/db"; +import { contentProjectTags, swarmProjects } from "@/db/schema"; +import { authenticateEvent } from "@/lib/auth"; + +/** + * POST /api/swarm/projects/:id/tags + * Tag content with a project. + * Body: { contentType: 'message'|'chat_message'|'notebook_page'|'directory_link', contentId: string } + */ +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const projectId = getRouterParam(event, "id"); + if (!projectId) { + return new Response(JSON.stringify({ error: "Project id required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const body = await readBody(event); + const { contentType, contentId } = body || {}; + + const validTypes = [ + "message", + "chat_message", + "notebook_page", + "directory_link", + ]; + if (!contentType || !validTypes.includes(contentType)) { + return new Response( + JSON.stringify({ + error: `contentType must be one of: ${validTypes.join(", ")}`, + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + if (!contentId) { + return new Response(JSON.stringify({ error: "contentId required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + // Verify project exists + const [project] = await db + .select({ id: swarmProjects.id }) + .from(swarmProjects) + .where(eq(swarmProjects.id, projectId)) + .limit(1); + + if (!project) { + return new Response(JSON.stringify({ error: "Project not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + // Check for existing tag (upsert) + const [existing] = await db + .select({ id: contentProjectTags.id }) + .from(contentProjectTags) + .where( + and( + eq(contentProjectTags.projectId, projectId), + eq(contentProjectTags.contentType, contentType), + eq(contentProjectTags.contentId, String(contentId)), + ), + ) + .limit(1); + + if (existing) { + return { id: existing.id, message: "Already tagged" }; + } + + const [tag] = await db + .insert(contentProjectTags) + .values({ + projectId, + contentType, + contentId: String(contentId), + taggedBy: auth.identity, + }) + .returning(); + + return tag; +}); diff --git a/server/routes/api/swarm/tasks/[id]/notebook-pages.get.ts b/server/routes/api/swarm/tasks/[id]/notebook-pages.get.ts index 1602212..117644a 100644 --- a/server/routes/api/swarm/tasks/[id]/notebook-pages.get.ts +++ b/server/routes/api/swarm/tasks/[id]/notebook-pages.get.ts @@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => { : sql`${swarmTaskNotebookPages.taskId} = ${id} AND ( ${notebookPages.taggedUsers} IS NULL OR jsonb_array_length(${notebookPages.taggedUsers}) = 0 - OR ${notebookPages.taggedUsers} @> ${sql.raw(`'["${auth.identity}"]'::jsonb`)} + OR ${notebookPages.taggedUsers} @> ${sql`${JSON.stringify([auth.identity])}::jsonb`} OR ${notebookPages.createdBy} = ${auth.identity} )`; diff --git a/src/components/markdown-editor.tsx b/src/components/markdown-editor.tsx index d4dd65a..1342268 100644 --- a/src/components/markdown-editor.tsx +++ b/src/components/markdown-editor.tsx @@ -41,6 +41,8 @@ interface MarkdownEditorProps { token?: string; /** Callback when viewers change */ onViewersChange?: (viewers: string[]) => void; + /** Callback when page becomes readonly (locked/archived) or editable again */ + onReadonlyChange?: (readonly: boolean, reason?: string) => void; } // Light theme matching shadcn @@ -93,18 +95,23 @@ export function MarkdownEditor({ pageId, token, onViewersChange, + onReadonlyChange, }: MarkdownEditorProps) { const containerRef = useRef(null); const viewRef = useRef(null); const wsRef = useRef(null); const onChangeRef = useRef(onChange); const onViewersRef = useRef(onViewersChange); + const onReadonlyRef = useRef(onReadonlyChange); const ydocRef = useRef(null); const ytextRef = useRef(null); const _isExternalUpdate = useRef(false); const initializedRef = useRef(false); + const valueRef = useRef(value); + valueRef.current = value; onChangeRef.current = onChange; onViewersRef.current = onViewersChange; + onReadonlyRef.current = onReadonlyChange; // Stable refs for pageId/token const pageIdRef = useRef(pageId); @@ -259,6 +266,10 @@ export function MarkdownEditor({ Y.applyUpdate(ydoc, update, "server"); } else if (msg.type === "viewers" && Array.isArray(msg.viewers)) { onViewersRef.current?.(msg.viewers); + } else if (msg.type === "readonly") { + onReadonlyRef.current?.(true, msg.reason); + } else if (msg.type === "editable") { + onReadonlyRef.current?.(false); } } catch {} }; @@ -292,7 +303,7 @@ export function MarkdownEditor({ const view = new EditorView({ state: EditorState.create({ - doc: pageIdRef.current ? "" : value, + doc: pageIdRef.current ? "" : valueRef.current, extensions, }), parent: containerRef.current, @@ -311,7 +322,7 @@ export function MarkdownEditor({ initializedRef.current = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [disabled, placeholder, value]); + }, [disabled, placeholder]); // For non-collaborative mode, sync external value changes useEffect(() => { diff --git a/src/db/schema.ts b/src/db/schema.ts index 2cf7d21..0aeb8ec 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -370,6 +370,7 @@ export const notebookPages = pgTable( reviewAt: timestamp("review_at", { withTimezone: true }), locked: boolean("locked").notNull().default(false), lockedBy: varchar("locked_by", { length: 50 }), + archivedAt: timestamp("archived_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -400,6 +401,61 @@ export const directoryEntries = pgTable( (table) => [index("idx_directory_created_at").on(table.createdAt)], ); +// ============================================================ +// CONTENT ↔ PROJECT TAGS (polymorphic tagging) +// ============================================================ + +export const contentProjectTags = pgTable( + "content_project_tags", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + projectId: text("project_id") + .notNull() + .references(() => swarmProjects.id, { onDelete: "cascade" }), + contentType: varchar("content_type", { length: 20 }).notNull(), // 'message' | 'chat_message' | 'notebook_page' | 'directory_link' + contentId: text("content_id").notNull(), + taggedBy: varchar("tagged_by", { length: 50 }).notNull(), + taggedAt: timestamp("tagged_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index("idx_content_project_tags_project").on(table.projectId), + index("idx_content_project_tags_content").on( + table.contentType, + table.contentId, + ), + ], +); + +// ============================================================ +// ATTACHMENTS β€” files on tasks and notebook pages +// ============================================================ + +export const attachments = pgTable( + "attachments", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + entityType: varchar("entity_type", { length: 20 }).notNull(), // 'task' | 'notebook_page' + entityId: text("entity_id").notNull(), + filename: text("filename").notNull(), // stored filename (uuid + ext) + originalName: text("original_name").notNull(), + mimeType: varchar("mime_type", { length: 100 }).notNull(), + size: integer("size").notNull(), // bytes + createdBy: varchar("created_by", { length: 50 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index("idx_attachments_entity").on(table.entityType, table.entityId), + ], +); + // ============================================================ // TYPES // ============================================================ @@ -421,3 +477,5 @@ export type Invite = typeof invites.$inferSelect; export type DirectoryEntry = typeof directoryEntries.$inferSelect; export type NotebookPage = typeof notebookPages.$inferSelect; export type SwarmTaskNotebookPage = typeof swarmTaskNotebookPages.$inferSelect; +export type Attachment = typeof attachments.$inferSelect; +export type ContentProjectTag = typeof contentProjectTags.$inferSelect; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index c4318ed..6349187 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -8,190 +8,190 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from "./routes/__root"; -import { Route as AdminRouteImport } from "./routes/admin"; -import { Route as BuzzRouteImport } from "./routes/buzz"; -import { Route as DirectoryRouteImport } from "./routes/directory"; -import { Route as IndexRouteImport } from "./routes/index"; -import { Route as NotebookRouteImport } from "./routes/notebook"; -import { Route as OnboardRouteImport } from "./routes/onboard"; -import { Route as PresenceRouteImport } from "./routes/presence"; -import { Route as SwarmRouteImport } from "./routes/swarm"; +import { Route as rootRouteImport } from './routes/__root' +import { Route as SwarmRouteImport } from './routes/swarm' +import { Route as PresenceRouteImport } from './routes/presence' +import { Route as OnboardRouteImport } from './routes/onboard' +import { Route as NotebookRouteImport } from './routes/notebook' +import { Route as DirectoryRouteImport } from './routes/directory' +import { Route as BuzzRouteImport } from './routes/buzz' +import { Route as AdminRouteImport } from './routes/admin' +import { Route as IndexRouteImport } from './routes/index' const SwarmRoute = SwarmRouteImport.update({ - id: "/swarm", - path: "/swarm", + id: '/swarm', + path: '/swarm', getParentRoute: () => rootRouteImport, -} as any); +} as any) const PresenceRoute = PresenceRouteImport.update({ - id: "/presence", - path: "/presence", + id: '/presence', + path: '/presence', getParentRoute: () => rootRouteImport, -} as any); +} as any) const OnboardRoute = OnboardRouteImport.update({ - id: "/onboard", - path: "/onboard", + id: '/onboard', + path: '/onboard', getParentRoute: () => rootRouteImport, -} as any); +} as any) const NotebookRoute = NotebookRouteImport.update({ - id: "/notebook", - path: "/notebook", + id: '/notebook', + path: '/notebook', getParentRoute: () => rootRouteImport, -} as any); +} as any) const DirectoryRoute = DirectoryRouteImport.update({ - id: "/directory", - path: "/directory", + id: '/directory', + path: '/directory', getParentRoute: () => rootRouteImport, -} as any); +} as any) const BuzzRoute = BuzzRouteImport.update({ - id: "/buzz", - path: "/buzz", + id: '/buzz', + path: '/buzz', getParentRoute: () => rootRouteImport, -} as any); +} as any) const AdminRoute = AdminRouteImport.update({ - id: "/admin", - path: "/admin", + id: '/admin', + path: '/admin', getParentRoute: () => rootRouteImport, -} as any); +} as any) const IndexRoute = IndexRouteImport.update({ - id: "/", - path: "/", + id: '/', + path: '/', getParentRoute: () => rootRouteImport, -} as any); +} as any) export interface FileRoutesByFullPath { - "/": typeof IndexRoute; - "/admin": typeof AdminRoute; - "/buzz": typeof BuzzRoute; - "/directory": typeof DirectoryRoute; - "/notebook": typeof NotebookRoute; - "/onboard": typeof OnboardRoute; - "/presence": typeof PresenceRoute; - "/swarm": typeof SwarmRoute; + '/': typeof IndexRoute + '/admin': typeof AdminRoute + '/buzz': typeof BuzzRoute + '/directory': typeof DirectoryRoute + '/notebook': typeof NotebookRoute + '/onboard': typeof OnboardRoute + '/presence': typeof PresenceRoute + '/swarm': typeof SwarmRoute } export interface FileRoutesByTo { - "/": typeof IndexRoute; - "/admin": typeof AdminRoute; - "/buzz": typeof BuzzRoute; - "/directory": typeof DirectoryRoute; - "/notebook": typeof NotebookRoute; - "/onboard": typeof OnboardRoute; - "/presence": typeof PresenceRoute; - "/swarm": typeof SwarmRoute; + '/': typeof IndexRoute + '/admin': typeof AdminRoute + '/buzz': typeof BuzzRoute + '/directory': typeof DirectoryRoute + '/notebook': typeof NotebookRoute + '/onboard': typeof OnboardRoute + '/presence': typeof PresenceRoute + '/swarm': typeof SwarmRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport; - "/": typeof IndexRoute; - "/admin": typeof AdminRoute; - "/buzz": typeof BuzzRoute; - "/directory": typeof DirectoryRoute; - "/notebook": typeof NotebookRoute; - "/onboard": typeof OnboardRoute; - "/presence": typeof PresenceRoute; - "/swarm": typeof SwarmRoute; + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/admin': typeof AdminRoute + '/buzz': typeof BuzzRoute + '/directory': typeof DirectoryRoute + '/notebook': typeof NotebookRoute + '/onboard': typeof OnboardRoute + '/presence': typeof PresenceRoute + '/swarm': typeof SwarmRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; + fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - | "/" - | "/admin" - | "/buzz" - | "/directory" - | "/notebook" - | "/onboard" - | "/presence" - | "/swarm"; - fileRoutesByTo: FileRoutesByTo; + | '/' + | '/admin' + | '/buzz' + | '/directory' + | '/notebook' + | '/onboard' + | '/presence' + | '/swarm' + fileRoutesByTo: FileRoutesByTo to: - | "/" - | "/admin" - | "/buzz" - | "/directory" - | "/notebook" - | "/onboard" - | "/presence" - | "/swarm"; + | '/' + | '/admin' + | '/buzz' + | '/directory' + | '/notebook' + | '/onboard' + | '/presence' + | '/swarm' id: - | "__root__" - | "/" - | "/admin" - | "/buzz" - | "/directory" - | "/notebook" - | "/onboard" - | "/presence" - | "/swarm"; - fileRoutesById: FileRoutesById; + | '__root__' + | '/' + | '/admin' + | '/buzz' + | '/directory' + | '/notebook' + | '/onboard' + | '/presence' + | '/swarm' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute; - AdminRoute: typeof AdminRoute; - BuzzRoute: typeof BuzzRoute; - DirectoryRoute: typeof DirectoryRoute; - NotebookRoute: typeof NotebookRoute; - OnboardRoute: typeof OnboardRoute; - PresenceRoute: typeof PresenceRoute; - SwarmRoute: typeof SwarmRoute; + IndexRoute: typeof IndexRoute + AdminRoute: typeof AdminRoute + BuzzRoute: typeof BuzzRoute + DirectoryRoute: typeof DirectoryRoute + NotebookRoute: typeof NotebookRoute + OnboardRoute: typeof OnboardRoute + PresenceRoute: typeof PresenceRoute + SwarmRoute: typeof SwarmRoute } -declare module "@tanstack/react-router" { +declare module '@tanstack/react-router' { interface FileRoutesByPath { - "/swarm": { - id: "/swarm"; - path: "/swarm"; - fullPath: "/swarm"; - preLoaderRoute: typeof SwarmRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/presence": { - id: "/presence"; - path: "/presence"; - fullPath: "/presence"; - preLoaderRoute: typeof PresenceRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/onboard": { - id: "/onboard"; - path: "/onboard"; - fullPath: "/onboard"; - preLoaderRoute: typeof OnboardRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/notebook": { - id: "/notebook"; - path: "/notebook"; - fullPath: "/notebook"; - preLoaderRoute: typeof NotebookRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/directory": { - id: "/directory"; - path: "/directory"; - fullPath: "/directory"; - preLoaderRoute: typeof DirectoryRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/buzz": { - id: "/buzz"; - path: "/buzz"; - fullPath: "/buzz"; - preLoaderRoute: typeof BuzzRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/admin": { - id: "/admin"; - path: "/admin"; - fullPath: "/admin"; - preLoaderRoute: typeof AdminRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/": { - id: "/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof IndexRouteImport; - parentRoute: typeof rootRouteImport; - }; + '/swarm': { + id: '/swarm' + path: '/swarm' + fullPath: '/swarm' + preLoaderRoute: typeof SwarmRouteImport + parentRoute: typeof rootRouteImport + } + '/presence': { + id: '/presence' + path: '/presence' + fullPath: '/presence' + preLoaderRoute: typeof PresenceRouteImport + parentRoute: typeof rootRouteImport + } + '/onboard': { + id: '/onboard' + path: '/onboard' + fullPath: '/onboard' + preLoaderRoute: typeof OnboardRouteImport + parentRoute: typeof rootRouteImport + } + '/notebook': { + id: '/notebook' + path: '/notebook' + fullPath: '/notebook' + preLoaderRoute: typeof NotebookRouteImport + parentRoute: typeof rootRouteImport + } + '/directory': { + id: '/directory' + path: '/directory' + fullPath: '/directory' + preLoaderRoute: typeof DirectoryRouteImport + parentRoute: typeof rootRouteImport + } + '/buzz': { + id: '/buzz' + path: '/buzz' + fullPath: '/buzz' + preLoaderRoute: typeof BuzzRouteImport + parentRoute: typeof rootRouteImport + } + '/admin': { + id: '/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AdminRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -204,16 +204,16 @@ const rootRouteChildren: RootRouteChildren = { OnboardRoute: OnboardRoute, PresenceRoute: PresenceRoute, SwarmRoute: SwarmRoute, -}; +} export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) - ._addFileTypes(); - -import type { getRouter } from "./router.tsx"; + ._addFileTypes() -declare module "@tanstack/react-start" { +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { interface Register { - ssr: true; - router: Awaited>; + ssr: true + router: Awaited> } } diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 91ae2ae..07cc57d 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -1511,7 +1511,7 @@ function AuthPanel() { const [inviteTab, setInviteTab] = useState<"active" | "used">("active"); const [tokenTab, setTokenTab] = useState<"active" | "revoked">("active"); - const fetchAll = async () => { + const fetchAll = useCallback(async () => { setLoading(true); try { const [inv, tok] = await Promise.all([ @@ -1523,7 +1523,7 @@ function AuthPanel() { } finally { setLoading(false); } - }; + }, []); useEffect(() => { fetchAll(); diff --git a/src/routes/notebook.tsx b/src/routes/notebook.tsx index b036f1b..9917f24 100644 --- a/src/routes/notebook.tsx +++ b/src/routes/notebook.tsx @@ -52,6 +52,7 @@ interface PageSummary { lockedBy: string | null; expiresAt: string | null; reviewAt: string | null; + archivedAt: string | null; createdAt: string; updatedAt: string; } @@ -457,6 +458,29 @@ function PageEditor({ .finally(() => setLoading(false)); }, [pageId]); + // Auto-refresh in preview mode (poll every 10s for changes) + useEffect(() => { + if (mode !== "preview") return; + const interval = setInterval(async () => { + try { + const data = await api.getNotebookPage(pageId); + if (data.page) { + // Only update if content actually changed + if (data.page.content !== contentRef.current) { + setContent(data.page.content); + contentRef.current = data.page.content; + } + setPage(data.page); + // Only update title if it changed server-side (avoid overwriting in-progress edits) + setTitle((prev) => + prev !== data.page.title ? data.page.title : prev, + ); + } + } catch {} + }, 10_000); + return () => clearInterval(interval); + }, [mode, pageId]); + const handleContentChange = useCallback((val: string) => { setContent(val); contentRef.current = val; @@ -477,13 +501,15 @@ function PageEditor({ locked: !page.locked, }); setPage(data.page); + // Switch to preview when locking + if (data.page.locked) setMode("preview"); } catch (e: any) { alert(e?.message ?? "Failed to toggle lock"); } }; const handleDelete = async () => { - if (!confirm("Delete this page permanently?")) return; + if (!confirm("Archive this page? It can be restored later.")) return; try { await api.deleteNotebookPage(pageId); onBack(); @@ -495,7 +521,8 @@ function PageEditor({ const isOwnerOrAdmin = page ? identity === page.createdBy || identity === "chris" : false; - const isLocked = page?.locked && !isOwnerOrAdmin; + const isLocked = !!page?.locked; + const isArchived = !!page?.archivedAt; if (loading) { return ( @@ -611,7 +638,7 @@ function PageEditor({ size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={handleDelete} - title="Delete page" + title="Archive page" > @@ -625,10 +652,21 @@ function PageEditor({ onChange={(e) => setTitle(e.target.value)} onBlur={handleTitleSave} className="text-lg font-semibold border-0 border-b rounded-none px-0 mb-2 focus-visible:ring-0" - disabled={isLocked} + disabled={isLocked || isArchived} placeholder="Page title" /> + {/* Archived banner */} + {isArchived && ( +
+ + + This page was archived on {formatDate(page.archivedAt!)}. Content + is read-only. + +
+ )} + {/* Expiration / Review banners */} {page.expiresAt && new Date(page.expiresAt) < new Date() && (
@@ -698,7 +736,15 @@ function PageEditor({ @@ -714,6 +760,19 @@ function PageEditor({ pageId={pageId} token={authToken || undefined} onViewersChange={setViewers} + onReadonlyChange={(ro) => { + if (ro) { + // Page became locked/archived while editing β€” reload page state and switch to preview + api.getNotebookPage(pageId).then((data: any) => { + if (data.page) { + setPage(data.page); + setContent(data.page.content); + contentRef.current = data.page.content; + } + }); + setMode("preview"); + } + }} /> ) : (