Compare commits

53 Commits

Author SHA1 Message Date
egutierrez 466a055f72 chore: auto-commit (1 archivos)
- app.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 15:49:55 +02:00
egutierrez a934897099 merge: fill required Área Solicitante on Jira create (v0.5.2) 2026-06-01 15:41:19 +02:00
egutierrez 0687b65ea2 fix(jira): fill required 'Área Solicitante' on Epic create
Project DATA's Epic and Mejora issue types mark customfield_10158
('Área Solicitante', a single-select) as required on the create screen.
The create payload omitted it, so enabling card.created sync produced
HTTP 400 'Solicitante is required'.

Add RequesterField/RequesterMap/RequesterDefault to jiraConfig. create()
and update() now inject the field as a {value:<option>} single-select,
resolved from the card requester via the map (case-insensitive) or the
default. Kanban requesters are person names, not departments, so cards
fall through to requester_default ('Transformación' for our setup).

seed-jira-data gains --requester-field (default customfield_10158) and
--requester-default (default Transformación); the existing-module branch
now merges config so operator UI edits (e.g. a requester_map) survive a
re-seed. Validated against Jira: Epic create with the field succeeds 201
(reporter auto-defaults to the token owner).
2026-06-01 15:41:19 +02:00
egutierrez 87e8f62544 merge: jira sync on card creation (v0.5.1) 2026-06-01 15:25:26 +02:00
egutierrez 0d8ec1e8e7 fix(jira): emit card.created so card creation syncs to Jira
handleCreateCard only published board.invalidated, which is not in the
module event filter, so the dispatcher dropped it and jiraHandler.create
never ran. Newly created cards therefore never produced a Jira issue,
unlike moves (card.moved) and chat (message.created) which already synced.

Emit card.created after assignee/tags are applied so the synced issue
carries them. board.invalidated is kept for the SPA refetch path. No loop
risk (card.created fires only from the HTTP handler) and no double-create
(board.invalidated stays out of the filter).
2026-06-01 15:25:25 +02:00
egutierrez d4558667f6 feat(jira): menu 'Jira' (rename) + modal con tabs Importar/Comprobar columnas
UI:
- Menu avatar dropdown: 'Importar de Jira' -> 'Jira' (renombrado).
- ImportJiraModal.tsx eliminado. Sustituido por JiraModal.tsx con Mantine Tabs:
  * 'Importar de Jira': UI heredada del modal anterior intacta.
  * 'Comprobar columnas': nueva. Lista cards linked y muestra desincronizadas
    (kanban col vs Jira status actual). Por cada row: kanban col + expected jira
    status + jira status real. Checkbox multi-select + boton 'Sincronizar' que
    empuja Jira al status correcto (kanban gana).

Backend:
- GET /api/jira/check-columns: walk cards.jira_key != ''. Por cada uno GET
  /rest/api/3/issue/{key}?fields=status. Compara status real vs status_map.
  Devuelve {rows[], total, mismatches, in_sync, status_map, reverse_map}.
- POST /api/jira/reconcile-columns {card_ids[], direction:'kanban-wins'}:
  reusa jiraHandler.transitionToStatus para empujar cada issue al status del
  status_map de su columna kanban actual + actualiza cards.jira_last_status.
- Helper listLinkedCardsForCheck en jira_import.go.

Direction='kanban-wins' default. Reverse direction (jira-wins) no soportado
por ahora: mover cards desde el server tiene efectos colaterales (eventos,
notificaciones, timers) que no quiero disparar masivos sin pensar.
2026-05-29 15:18:59 +02:00
egutierrez 9b0b6e516c fix(jira): emitir card.moved al cambiar columna + adaptive indicator polling
Bug: handleMoveCard solo emitia board.invalidated. Dispatcher mapeaba a
update() (PUT summary/description/labels) NUNCA a transition(), asi que
mover una card en kanban no transicionaba su Jira issue de columna. Solo
los labels reflejaban el cambio.

Fix backend (handlers.go):
- handleMoveCard ahora lee column_id antes del MoveCard. Si la card crusa
  columnas (prev != new) publica 'card.moved' antes de 'board.invalidated'.
  El dispatcher reconoce 'card.moved' y ejecuta transition() -> Jira status
  cambia + labels sincronizan.
- Reorder dentro de la misma columna sigue como antes: solo board.invalidated
  para refetch del cliente sin tocar Jira.
- nuevo helper db.lookupCardColumnID(cardID).

UX frontend (JiraSyncIndicator):
- Polling adaptativo: 5s steady, 1s mientras inflight=true. El usuario VE
  el yellow durante el sync.
- Listener de window CustomEvent 'kanban-card-moved' (cardId match) que
  fuerza un refetch inmediato (~150ms) tras drop. App.tsx dispara el evento
  tras api.moveCard resolve. Yellow visible casi instantaneo en lugar de
  esperar al proximo tick steady.
2026-05-29 15:01:51 +02:00
egutierrez c5113f75a5 feat(jira): issue_type=Epic + AssigneeMap + CLI resync-jira-fields
Cambios:
- jiraConfig: nuevo campo AssigneeMap (kanban_user_id -> jira_accountId).
- jiraHandler.create() y update(): aplican fields.assignee={accountId} cuando
  card.AssigneeID esta en el map. NO se borra el assignee de Jira cuando no
  hay mapeo (evita pisar asignaciones manuales).
- resolveJiraAssignee: helper compartido.
- seed-jira-data: cambio issue_type default Tarea Tecnica -> Epic (board 33
  filtra issuetype=Epic). assignee_map inyectada con 3 mapeos confirmados:
    egutierrez (Enmaa)  -> 712020:2cf3b82f-... (Enmanuel Gutierrez Perez)
    amassaguer (alfon)  -> 712020:3f3ca9e1-... (Alfonso Massaguer Gomez)
    ntajuelo   (Nat)    -> 712020:feb5f7c5-... (Natalia Tajuelo Gomez)
- Nueva CLI 'kanban resync-jira-fields' con flags
    --set-issuetype/--set-assignee/--set-labels/--dry-run/--limit/--batch-size/--pause-sec
  Idempotente. PUT /rest/api/3/issue/{key} con los fields del config actual.
  Usado para patchear las 127 issues ya creadas con Tarea Tecnica -> Epic +
  assignee (donde mapea).
- Ejecutado: 127/127 OK, 0 fail. Board 33 ahora muestra 219 issues totales
  (92 Epics previas + 127 nuevas). Sample verificado contra Jira REST API.
2026-05-29 14:52:48 +02:00
egutierrez cd14e81487 feat(jira): kanban backfill-jira CLI con batches + ejecutado backfill 127 cards
Subcomando 'kanban backfill-jira':
- --batch-size N --pause-sec S: procesa N cards entre pausas para no saturar Jira REST.
- --limit N: cap total.
- --column NAME: filtro case-insensitive por columna kanban.
- --dry-run: lista candidatos + counts por columna sin tocar Jira.

Walk: cards con jira_key vacio, no borradas, no archivadas, ORDER BY created_at ASC.
Por cada card: jiraHandler.Handle(card.created event) que crea issue + transition al
status del status_map + labels. Tras success/failure updateCardJiraSync persiste
jira_last_status, jira_last_sync_at, jira_last_error.

Ejecutado contra Jira DATA project: 127 issues creadas (DATA-276..DATA-402), 0 fail.
Distribucion final:
  Done: 85 (HECHO)
  In Progress: 18 (HACIENDO 7 + Bloqueadas 11, las ultimas con label 'blocked')
  IMPLEMENTADO: 14 (PNDNT FEEDBACK)
  To Do: 6 (DEUDA TECNICA)
  CREADO: 4 (IDEAS)
2026-05-29 14:37:56 +02:00
egutierrez c3cc42b350 feat(jira): indicator per-card + import view desde Jira board 33
Backend:
- migration 018: cards.jira_last_status / sync_at / error (estado persistido del ultimo
  sync para render UI sin polling Jira).
- Dispatcher: sync.Map inflight para 'yellow' realtime + persistencia de exito/fallo
  en cards tras cada dispatch attempt.
- GET /api/cards/{id}/jira-sync: devuelve {jira_key, last_status, last_sync_at,
  last_error, inflight, issue_url} para el tooltip del indicador.
- GET /api/jira/issues: lista issues del board 33 con flag already_imported +
  mapped_column_id (reverse status_map). Filtros include_imported, limit.
- POST /api/jira/import: multi-key. Cada issue -> CreateCard + setCardJiraKey +
  seed jira_last_status. Cae en columna mapeada por status, o en fallback_column_id.
  ADF de description extraido a texto plano.

Frontend:
- JiraSyncIndicator: dot gris/amarillo/verde/rojo bajo IconDotsVertical de cada card.
  Mantine HoverCard con jira_key, status, last_sync, last_error, link 'Abrir en Jira'.
  Poll cada 10s, refresh-tick opcional.
- KanbanCard: agrupa menu + indicator en Stack vertical (indicator debajo de los 3 dots).
- ImportJiraModal: modal admin con tabla de issues. Checkbox por fila, filtro por texto,
  toggle 'mostrar ya importadas', Select de columna fallback. Tras import recarga board.
- App.tsx: nueva entrada de menu 'Importar de Jira' (admin) y ImportJiraModal mounted.

Backend tests siguen verdes (test mock cubre transitions endpoints).
Frontend pnpm build OK.
2026-05-29 12:00:26 +02:00
egutierrez 5744b82f58 feat(jira): issue_type config + labels_map + status_map default DATA + transition tras create
- jiraConfig: campos IssueType + LabelsMap (kanban col -> labels Jira). Default
  IssueType='Tarea Tecnica' (DATA project no tiene Task).
- create(): usa c.IssueType y aplica labels iniciales. Despues del POST /issue
  ejecuta transitionToStatus para mover la card recien creada al status del
  status_map, asi no aterriza en el initial workflow status (CREADO o To Do)
  sino donde toca segun la columna kanban.
- update() y transition(): aplican labels en cada sync (PUT replaces array).
  Card que sale de Bloqueadas pierde el label 'blocked' automaticamente.
- transitionToStatus: helper compartido entre create() y transition().
- seed-jira-data: inyecta status_map por defecto para nuestras 6 columnas
  (HACIENDO -> In Progress, PNDNT FEEDBACK -> IMPLEMENTADO, HECHO -> Done,
  IDEAS -> CREADO, DEUDA TECNICA -> To Do, Bloqueadas -> In Progress) y
  labels_map (Bloqueadas -> ['blocked']).
- modules_test: mock Jira tambien responde /transitions endpoints.
2026-05-29 11:44:04 +02:00
egutierrez ef197236db feat(modules): jira scoped a project=DATA + board=33 con seed CLI desde pass
Cambios:
- jiraConfig: nuevo campo BoardID. TestConnection valida que board.location.projectKey
  coincide con ProjectKey declarado. Refuse mismatched scopes so a typo in
  project_key cannot create issues in the wrong project.
- backend/seed_jira.go: subcomando 'kanban seed-jira-data' lee credenciales
  desde pass (jira/anjana/{email,api-token,domain}) e inserta module row con
  kind=jira, project_key=DATA, board_id=33, event_filter sensible. Idempotente
  (upsert por name). status_map vacio por defecto (operator lo edita por UI).
- main.go: wire del nuevo subcomando.

Requiere KANBAN_MODULE_KEY env var para encriptar/desencriptar config. El
servidor que ejecuta el dispatcher debe usar el mismo valor.
2026-05-28 12:56:33 +02:00
egutierrez 65771ebb12 feat(mcp): mint-token CLI + get_card / delete_comment tools + executeToolAs(actor)
Net-new capacidades recuperadas del WIP stash que el merge notif no traia:

- mint-token CLI subcommand: 'kanban mint-token --user <id> --name <pc>' genera token bearer
  para configurar Claude Code u otros clientes MCP HTTP sin tocar la UI.
- executeToolAs(db, name, input, actor): variante actor-aware de executeTool. El dispatcher
  HTTP /mcp pasa el user_id resuelto del bearer token; tools per-user (add_comment,
  delete_comment) lo usan como autor sin que el llamante pueda forjarlo.
- get_card tool: lookup por id o seq_num. Devuelve Card completa.
- delete_comment tool: borra card_message; solo el autor original (validado en DB).

executeTool() sigue siendo el wrapper legacy sin actor para chat WS.
2026-05-28 09:36:48 +02:00
egutierrez 084defe014 Merge branch 'merge/notifications-modules' into master
Trae notifications-realtime + modules + MCP tokens al master preservando
files attachments (issue 0128). Migrations renumeradas: 014_card_files (master),
015_notifications, 016_modules, 017_mcp_tokens (notif renumerada).

Bump version 0.2.0 -> 0.5.0.
2026-05-27 18:50:44 +02:00
egutierrez abb787facd chore: go mod tidy tras merge notif (nuevas deps de modules/notifications) 2026-05-27 18:50:30 +02:00
egutierrez 6a35bdec42 chore: auto-commit (5 archivos)
- backend/dist/assets/index-DFLRkdHe.js
- backend/dist/assets/index-DT3pghXY.js
- backend/dist/assets/index-UVzY_37O.js
- backend/dist/index.html
- frontend/src/components/CardChatPanel.tsx

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:48:14 +02:00
egutierrez 065070cec7 fix(tests): card_history tool ahora retorna *CardHistoryResponse — desempaquetar .ColumnHistory 2026-05-27 18:46:18 +02:00
egutierrez 172850178d merge: bring notifications-realtime + modules into master (preserves files attachments) 2026-05-27 18:43:54 +02:00
egutierrez d13993c0d7 chore(migrations): renumber 014/015/016 -> 015/016/017 to avoid collision with master 014_card_files 2026-05-27 18:38:46 +02:00
egutierrez 5b30fb1ded chore: restore control.sh TUI launcher from issue/notifications-realtime
Script vivia solo en rama issue/notifications-realtime y se perdio al
hacer checkout master para el branch 0128. Es self-contained (no toca
otros archivos de esa rama). Permite ./control.sh para gestionar
backend (WSL) + frontend Vite (Windows).
2026-05-27 11:13:03 +02:00
dataforge 87fd95314e Merge pull request 'feat(kanban): adjuntos de archivos por card (issue 0128)' (#1) from issue/0128-files-attachments into master 2026-05-27 09:04:38 +00:00
egutierrez 472fa25bae build(frontend): rebuild dist with XSS scheme guard 2026-05-27 11:04:20 +02:00
egutierrez aab4f12fc4 fix(0128): XSS scheme allowlist + drop dead fileID
review findings:
- MessageBody: only http(s) and relative paths allowed for links;
  data:image/* allowed for inline images. Rejects javascript:,
  data:text/html, vbscript: which would execute via <a href>.
  Unsafe matches fall back to plain text.
- files.go: remove unused fileID var generated then discarded.
2026-05-27 11:04:20 +02:00
egutierrez e86c93cb73 build(frontend): rebuild embedded dist with files UI 2026-05-27 10:52:15 +02:00
egutierrez 489d2bbef6 chore: bump kanban 0.1.0 -> 0.2.0 + e2e smoke (issue 0128)
- app.md: descripcion, e2e_checks smoke_files, doc Archivos, capability growth log
- .gitignore: uploads/
- e2e/files_smoke.sh: build, login, upload PNG, list, serve, delete
2026-05-27 10:52:06 +02:00
egutierrez ac5f016e7e feat(frontend): UI archivos en cards (issue 0128)
- CardFilesPanel: tab Archivos con grid thumbs + boton subir/borrar
- CardForm: drag&drop en descripcion, inserta ref markdown en cursor
- CardChatPanel: drag&drop + boton paperclip, sube y envia ref como mensaje
- MessageBody: renderer markdown minimo (img inline + link chip)
- api.ts: listCardFiles, uploadCardFile (multipart), deleteCardFile
- types.ts: CardFile
2026-05-27 10:52:01 +02:00
egutierrez 2401eb5abc feat(backend): card file attachments (issue 0128)
- migration 014_card_files: tabla con soft-delete + index activo
- handlers POST/GET/DELETE en backend/files.go
- routes /api/cards/{id}/files, /api/files/{id}
- limite 10MB, storage en uploads/<card_id>/<random>__<safe>
2026-05-27 10:51:52 +02:00
egutierrez 12729b5166 chore: auto-commit (2 archivos)
- backend/mcp.go
- backend/tools.go

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:35:39 +02:00
egutierrez c28ae7d3c0 chore: auto-commit (12 archivos)
- app.md
- backend/handlers.go
- backend/main.go
- frontend/src/App.tsx
- frontend/src/api.ts
- frontend/vite.config.ts
- backend/mcp_http.go
- backend/mcp_tokens.go
- backend/mcp_tokens_handlers.go
- backend/migrations/016_mcp_tokens.sql
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:38:17 +02:00
egutierrez c9e15513c7 chore: auto-commit (23 archivos)
- app.md
- backend/dist/assets/index-CFDWXN9Z.js
- backend/dist/index.html
- backend/handlers.go
- backend/main.go
- backend/users.go
- e2e/smoke_live.sh
- frontend/src/App.tsx
- frontend/src/api.ts
- frontend/src/components/CardChatPanel.tsx
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:22:44 +02:00
egutierrez 2524340759 chore: auto-commit (21 archivos)
- app.md
- backend/dist/assets/index-D_Kep7Fb.js
- backend/dist/index.html
- backend/handlers.go
- backend/main.go
- frontend/src/App.tsx
- frontend/src/api.ts
- frontend/src/components/CardChatPanel.tsx
- frontend/src/components/LoginPage.tsx
- frontend/src/types.ts
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:17:04 +02:00
egutierrez 1923fd31a4 chore: auto-commit (1 archivos)
- registry.db

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:37:33 +02:00
egutierrez b599090876 chore: auto-commit (1 archivos)
- app.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:32:55 +02:00
egutierrez 69a0d351fc Merge issue 0094: kanban daily summary + pdf 2026-05-14 18:08:09 +02:00
egutierrez 9c5e76e03f feat(kanban): bocadillo agente + PDF descargable en reporte diario (issue 0094)
Anade tres capas sobre el reporte diario del issue 0093:

1) Bocadillo del agente: cuadro azul encima de "Tareas hechas" con un
   resumen en lenguaje natural (max 4 frases) generado por claude -p
   sobre el JSON del reporte. Botones Regenerar e icono Settings.

2) Settings del prompt: modal con textarea editable para el template
   del agente (key=daily_report_prompt). Compartido por todos los
   usuarios. Boton Restablecer por defecto.

3) PDF descargable: boton que abre ventana nueva con HTML imprimible
   (estilo A4, KPIs filtrados, tabla con enlaces absolutos por card).
   Permite compartir el listado de tareas hechas con los solicitantes.

Backend:
- Migration 013 anade tablas daily_summaries y settings; seed del
  prompt por defecto en castellano.
- daily_summary.go con GetSetting/SetSetting, GetDailySummary/Upsert,
  runClaudePrompt (envuelve claude -p) y GenerateDailySummary que
  orquesta DailyReportFor + plantilla + claude + persist.
- Nuevos endpoints:
  * GET  /api/reports/daily/summary
  * POST /api/reports/daily/summary
  * GET  /api/settings/{key}
  * PUT  /api/settings/{key}

Frontend:
- api.ts: getDailySummary, generateDailySummary, getSetting, setSetting.
- DailyReport.tsx: estado de summary, settingsOpen, promptDraft,
  filterRequester, filterAssignee, filteredDoneCards, exportPDF.
- Bocadillo con IconSparkles + IconRefresh + IconSettings.
- Modal de prompt con Guardar/Cancelar/Reset.
- Filtros Select por solicitante y asignado encima de la tabla.
- exportPDF abre window.open con HTML self-contained que incluye
  enlaces ${origin}/?card=${id} y window.print() automatico.

E2E nuevo (daily-summary-pdf.spec.ts): CRUD del setting, GET summary
shape, presencia del boton PDF/Settings/Regenerar en el modal. No
invoca claude real (binario externo, no disponible en CI).

Suite completa 11/11 pasa.
2026-05-14 18:08:09 +02:00
egutierrez fc7e6a34a7 feat(kanban): reporte diario al click en dia del calendario (issue 0093)
Adds a daily report dashboard accessible by clicking a day number in the
calendar view. Renders inside a full-width modal (90% width).

Backend (new file backend/reports.go):
- Type DailyReport with KPIs, rankings, done_cards list, reopened cards,
  3-bucket stale list (7/14/30d), lead time avg+p50+p95, 24-hour
  movement histogram, deadlines met/missed list, tag distribution and
  archived count.
- DB.DailyReportFor(date, tz) uses Europe/Madrid by default; computes
  [start,end) in local time, converts to UTC and queries:
  * cards.completed_at in range  -> done list
  * card_events kind=created in range -> created counts
  * card_column_history.entered_at in range -> moves + hourly
  * previousColumnWasDone() -> reopened detection
  * card_lock_history overlapping the day -> blocked_ms
  * stale buckets: open history entries on non-done columns aged >=7d
- New route GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid.

Frontend:
- api.ts: DailyReport type + dailyReport(date, tz?) call.
- New component DailyReportView (components/DailyReport.tsx):
  * 6 KPI cards (Hechas, Creadas, Movimientos, Bloqueado, Reabiertas,
    Deadlines on-time %).
  * 4 ranking cards (Top assignees done, Top assignees created,
    Top requesters atendidas, Top requesters aportadas).
  * Done cards table with click-to-jump (links open the card in board).
  * Mantine BarChart with movements per hour.
  * Tag chips, reopened list, deadlines list with late_ms, stale buckets.
- CalendarView wraps the day number in UnstyledButton with data-test
  attribute and forwards onOpenDailyReport.
- App.handleOpenDailyReport opens modals.open size 90% with the view;
  click on a card title closes the modal and jumps to the board with
  highlight (reuses existing handleJumpToCard).

Tests (e2e/daily-report.spec.ts):
- Endpoint shape: kpis, done_cards, hourly_moves[24], stale buckets.
- Calendar day click opens the modal with "Reporte diario" title and
  KPI labels visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 9d3ab5f0f3 feat(kanban): hide "Seleccionar Aleatorio" in done columns
The random-pick menu entry is meaningless for done columns — cards there
are already finished and now get auto-archived after 30 days (issue
0092). Gate the Menu.Item on !column.is_done so the action only appears
in active columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 9b503f0555 feat(kanban): archive automatico para cards Done +30 dias (issue 0092)
Adds an archive layer separate from the trash. Cards in is_done columns
that have been there for more than 30 days are auto-archived on the next
board load (throttled to once every 30 minutes). Archived cards leave
the board but stay in the DB and are listed in a new sidebar drawer
"Hecho (archivo)" below the existing Papelera, with a one-click restore.

Schema (migration 012_card_archived.sql):
- ALTER TABLE cards ADD COLUMN archived_at TEXT;
- NULL = active, ISO timestamp = archived. Independent from deleted_at.

Backend:
- Card.ArchivedAt + JSON; ListCardsWithTime filters archived_at IS NULL.
- New methods: ArchiveCard, UnarchiveCard, ListArchivedCards,
  AutoArchiveDoneOlderThan.
- New endpoints: GET /api/archive, POST /api/cards/:id/archive,
  POST /api/cards/:id/unarchive.
- handleGetBoard invokes maybeAutoArchive (atomic throttle, 30 min sweep,
  30 day cutoff). Errors logged but never block the board response.

Frontend:
- Card type + api.ts add the new field and helpers.
- App.tsx state for archive list, reload, archive/unarchive handlers.
- New sidebar drawer with toggle, count badge, restore button.
- KanbanCard gains an "Archivar" menu item (gated on isDone +
  onArchive prop) for manual archiving of any done card.

Tests:
- Playwright e2e/archive.spec.ts: manual archive via menu, drawer
  toggle, unarchive. Picks a done card via /api/board introspection so
  it stays stable regardless of board state.
- Auto-archive of >30d cards: not under e2e (real time travel needed);
  covered by code review of the SQL query and the throttle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez c4caff85be perf(kanban): split KanbanCard body into memoized child (dnd lag fix)
Drag perf measured via new Playwright spec drag-perf.spec.ts which drives
a slow drag across the biggest column (~35 cards) while capturing per-frame
durations via rAF inside the page. Pre-fix metrics in HECHO column:

  wrapper-renders=1942 body-renders=N/A
  p50=16.7ms p95=83.3ms max=116.7ms (12fps stalls)

Root cause: useSortable inside KanbanCardImpl subscribes to dnd-kit context;
every pointermove during a drag re-renders ALL cards in the SortableContext.
With the old monolithic component, each re-render rebuilt the full Stack +
Menu + 4 Popovers JSX tree — even though no data had changed.

Fix: split KanbanCardImpl into a thin outer (useSortable + Paper wrapper +
sticker overlay handler + style) and a memoed KanbanCardBody (Stack +
sticker overlay + popover state). All popover/requesterDraft local state
lives inside the body now, so its props are stable across drag and
React.memo skips the body work entirely.

Post-fix metrics:

  wrapper-renders=1943 body-renders=0
  p50=16.7ms p95=16.8ms max=50.0ms (steady 60fps with a single 33ms spike)

E2E thresholds tightened: p50<20, p95<50, max<60, body-renders<5. Regression
in any of these will fail CI.

Probe helpers (_probeRender / _probeBodyRender) are no-ops unless
window._cardRenderProbe is set. Production cost: ~3ns per render call.

Also: `now` clock interval already pauses while dragging (previous commit
e656e8c). `animateLayoutChanges:() => false` kept; it does not visibly
change reorder UX with this codebase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 7ba18f9114 fix(kanban): infinite ref-merge loop + drag lag
Two issues:

1. "Maximum update depth exceeded" inside Mantine's useMergedRef during
   drag. Root cause: the `data` object passed to dnd-kit's `useSortable`
   in KanbanCard and KanbanColumn was re-created on every render. Mantine
   Paper composes its internal ref with the consumer ref via
   useMergedRef, which uses a useCallback whose deps array contains the
   refs themselves. Whenever the underlying setNodeRef from useSortable
   became unstable (because dnd-kit's internal state churned on data
   identity change), the merged ref was reassigned each commit -> setState
   inside the ref callback -> next render -> new data identity -> loop.
   Wrap the sortable data in useMemo keyed on its real inputs.

2. Drag feels laggy. Each card listens to a 1-second `now` clock that
   re-renders the entire board to refresh the "time in column" label.
   Pause that interval while a drag is active so dnd-kit's per-pixel
   reconciliation does not also re-mount/re-layout every card every
   second. Tick resumes the moment the drag ends.

Also move the Select `data` array for the column max-time popover from
an inline expression to a module-level constant; same array identity
across all column instances and renders -> Mantine Combobox stops
re-running its diffing effect for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 76d85959f1 feat(kanban): sidebar edge zone now toggles (open + close)
The 32px left drag zone now also closes the sidebar when it is open.
Symmetric behaviour: dwell ≥400ms while dragging closes if open / opens
if closed.

To prevent a drag that starts with the pointer already inside the
sidebar (e.g. dragging a sidebar card itself) from immediately auto-
closing, the close action requires the pointer to have left the strip
at least once after the drag started. So:
- closed + drag-to-edge -> opens (unchanged).
- open + drag a card out, then move the card back to the edge -> closes.
- open + drag a sidebar card directly to the board -> nothing happens.

After a successful toggle the dwell flag resets, so the user must leave
the strip and re-enter to trigger another action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 257858a1f3 feat(kanban): drag-aware sidebar dropzone (issue 0091)
Add a 32px invisible strip on the left edge of the viewport that
auto-opens the sidebar when the user drags a card and dwells near the
edge for >=400ms. Removes the manual toggle step when moving cards to
sidebar-located columns.

- App.tsx: global mousemove listener while drag is active; 400ms hover
  timer; sets navOpen(true) when triggered; cancels on pointer leave or
  drag end. No auto-close on drag end (user keeps sidebar open).
- dropzone.css: subtle inset blue glow with pulse animation while
  pointer is inside the strip and a drag is active.
- KanbanColumn.tsx: add data-column-id and data-column-location to the
  Paper root for stable e2e selectors.
- e2e/sidebar-dropzone.spec.ts: Playwright test driving a slow drag
  to the left edge, asserting the strip arms, sidebar opens, and the
  card moves to a sidebar column via /api/board.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 30def13c55 feat(kanban): mejoras historial card — DONE check + tiempo por columna
HistoryModal now receives the board's columns via prop to enrich
history events with column metadata.

Timeline:
- Entries for column moves into a column with is_done=true now render
  with a green IconCheck bullet and the kind label "Hecho en columna"
  instead of the generic blue arrows / "Mueve a columna". Makes the
  card's "done" moments scannable at a glance.

Footer (below the timeline):
- Replaces the single Group-of-badges with a structured table showing
  one row per visited column: name, visits (entry count) and total time
  in column. DONE columns are flagged with a green check + bold.
- Total locked time keeps the same source (total_locked_ms) but moved
  to a header line above the table to declutter.
- Currently-active entry (exited_at=null) contributes now - entered_at
  to its row, so the table reflects live time.

App.tsx passes columns from board state when opening the history modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez bc502df48a feat(kanban): tiempo maximo via popover con unidad (issue 0089 followup)
Replace the native window.prompt with a Popover that mirrors the deadline
picker pattern: NumberInput + unit Select (minutos/horas/dias/semanas/meses).
The selected unit converts to minutes at save time; the column's stored
unit on the backend stays unchanged (max_time_minutes).

On open the popover pre-selects the largest unit that yields a clean
integer for the current value (e.g. 1440 -> 1 dia, 60 -> 1 hora).

Includes a trash icon to clear the limit and a Guardar button.
data-test selectors added for future e2e:
- column-max-time, column-max-time-input, column-max-time-unit,
  column-max-time-save.

Menu label now shows "(N dias)" / "(N semanas)" / etc. instead of "(N min)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez c93ac46c37 feat(kanban): Seleccionar Aleatorio en columna con ruleta (issue 0090)
Adds a column-level "Seleccionar Aleatorio" context menu entry that picks
a card at random from the column with a roulette-style highlight
animation that decelerates to the winner.

Selection respects existing filters (uses cardsByColumn which is the
post-filter view) and always excludes locked cards. Menu entry is
disabled when nothing pickable is left.

Implementation:
- KanbanColumn: new Menu.Item with IconDice5; data-test selector for e2e.
- onPickRandom prop wired from KanbanColumn -> App.
- handlePickRandom in App.tsx: cryptographically random winner via
  crypto.getRandomValues, 2 full laps + offset, cubic decay 50ms -> 220ms,
  follows the active card with scrollIntoView.
- src/styles/roulette.css: .kanban-roulette-active (blue pulse, single
  step) and .kanban-roulette-winner (green pulse + scale, ~1.6s).
  Imported globally from main.tsx.

Manual verification only (visual timing + needs real cards). Backend
untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez 9f4fd85db3 feat(kanban): tiempo maximo por columna con borde rojo (issue 0089)
Adds column-level max time limit. Cards whose time_in_column_ms exceeds
the limit show a red border + red halo. Columns marked as Done never
trigger the visual regardless of the limit (per spec).

Backend:
- Migration 011_column_max_time.sql adds columns.max_time_minutes
  INTEGER NOT NULL DEFAULT 0 (0 = no limit). Aditiva, idempotente.
- Column struct + ColumnPatch + UpdateColumn handle the new field;
  negatives clamp to 0; listing query includes it.
- handleUpdateColumn (PATCH /api/columns/:id) accepts max_time_minutes
  in the JSON body.

Frontend:
- Column TS interface + UpdateColumnInput updated.
- KanbanColumn context menu: new entry "Tiempo maximo" using
  window.prompt for low-friction config; shows current value when >0.
- KanbanCard receives columnOverdue prop calculated from the column
  state and card.time_in_column_ms; renders red border (var
  --mantine-color-red-6) with 2px width + 2px red halo when overdue.
- data-card-id, data-column-overdue, data-locked attributes on the card
  paper element so e2e tests / scripts can query state.

Tests: TestColumnMaxTimeMinutes_Defaults + _Update verify the schema
default, the clamp on negative input, and that updating max_time leaves
other fields untouched.

Visual regression of the red border kept out of automated e2e because
it requires either clock control or real cards aged > N minutes; will be
verified manually after merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:57:14 +02:00
egutierrez eb1c13d82c feat(kanban): requester input empty + keyboard nav (issue 0088)
CardForm: drop pre-fill of requester from logged user; Enter inside the
Autocomplete no longer submits the form (Mantine handles dropdown
selection; arrows + Enter pick option without closing modal). Submit
remains via "Crear" button or Ctrl+Enter from description.

Adds data-field="requester" and data-test="add-card" selectors for stable
e2e queries.

Tests:
- vitest component test (CardForm.test.tsx): empty input, Enter does not
  submit, submit only via button. Dropdown arrow nav covered by e2e
  (jsdom portal handling is brittle).
- Playwright e2e (requester-input.spec.ts) using new browser capability
  group (pw_kanban_login, pw_keyboard_sequence) from registry.
- seed_e2e_user CLI to create deterministic test user against
  operations.db (bcrypt via standard backend hash).

Setup additions (frontend/):
- vitest + @testing-library + jsdom devDeps
- @playwright/test devDep + playwright.config.ts
- src/test/setup.ts polyfills jsdom for Mantine (matchMedia,
  visualViewport, document.fonts, ResizeObserver)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:57:00 +02:00
egutierrez a34a8142cc chore: auto-commit (23 archivos)
- app.md
- backend/auth.go
- backend/db.go
- backend/dist/assets/index-CPqSy0gZ.js
- backend/dist/index.html
- backend/handlers.go
- backend/main.go
- frontend/src/App.tsx
- frontend/src/api.ts
- frontend/src/components/KanbanCard.tsx
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:40:22 +02:00
egutierrez f1ee116d3b chore: auto-commit (5 archivos)
- app.md
- backend/chat.go
- Dockerfile
- docker-compose.yml
- traefik-dynamic.yml

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:11:24 +02:00
egutierrez ce807ec2ee Merge quick/fix-ws-proxy: vite ws proxy + chat WS e2e 2026-05-09 15:00:41 +02:00
egutierrez 69ebc1e59b fix(chat): vite proxy ws + e2e tests para chat WebSocket
- frontend/vite.config.ts: anadir ws: true al proxy de /api para que el
  dev server (5180) reenvie WebSocket upgrade al backend (8095). Sin esto
  Firefox da "websocket error" al abrir /api/chat/ws en modo dev.
- e2e/chat_ws_e2e_test.go: 4 tests nuevos que arrancan el binario kanban
  en puerto efimero con un fake claude (bash script que emite NDJSON), se
  loguean via /api/auth/login y dialean /api/chat/ws con cookie de sesion.
  Verifican: deltas + done, tool_use + tool_result + board_changed,
  rechazo sin sesion, /api/tool sin token = 401.
- e2e/go.mod: anade nhooyr.io/websocket (cliente WS para tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:00:38 +02:00
egutierrez b166609523 Merge quick/mcp-streaming: MCP server + WebSocket chat streaming 2026-05-09 14:54:42 +02:00
egutierrez ce49fdf9ff feat(chat): MCP server + WebSocket streaming, replace XML actions
- Backend: kanban binary gana subcomando `kanban mcp` que actua como MCP
  server via stdio. Tools = mismo set que executeTool (14). El subprocess
  llama de vuelta al backend via /api/tool/{name} con token interno.
- Backend: nuevo endpoint POST /api/tool/{name} (auth: X-Internal-Token).
- Backend: chat.go refactor — POST /api/chat reemplazado por GET
  /api/chat/ws (WebSocket). Lanza claude -p con --output-format stream-json
  --verbose --mcp-config y reenvia eventos (delta/tool_use/tool_result/
  result/done/error) como mensajes JSON al cliente.
- Backend: usa funciones nuevas del registry claude_stream_go_core (spawn
  + parser NDJSON) y mcp_server_stdio_go_infra (JSON-RPC stdio).
- Frontend: streamChat sobre WebSocket. ChatPanel renderiza deltas en
  vivo, chips para tool_use, badges teal/red para tool_result.
- Borrado: extractActions, actionsBlockMarker, XML system prompt.
- Tests: 7 nuevos en backend (chat_ws_test.go + endpoint /api/tool).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:54:38 +02:00
105 changed files with 17040 additions and 1936 deletions
+5
View File
@@ -16,5 +16,10 @@ frontend/tsconfig.tsbuildinfo
# Local files
local_files/
# Card file attachments (issue 0128) — binarios en disco; metadata en card_files
uploads/
# Logs
*.log
frontend/test-results/
frontend/playwright-report/
+28
View File
@@ -0,0 +1,28 @@
FROM golang:1.25-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc g++ libsqlite3-dev pkg-config ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY . .
WORKDIR /build/apps/kanban/backend
RUN CGO_ENABLED=1 go build -ldflags='-s -w' -o /kanban .
# --- Runtime ---
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libsqlite3-0 libstdc++6 ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /kanban /usr/local/bin/kanban
WORKDIR /data
VOLUME /data
EXPOSE 8095
ENTRYPOINT ["/usr/local/bin/kanban"]
CMD ["--port", "8095", "--db", "/data/operations.db"]
+91 -2
View File
@@ -2,7 +2,8 @@
name: kanban
lang: go
domain: tools
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit) y tracking del tiempo que cada tarjeta pasa en cada columna. Frontend Vite + React + Mantine v9 embebido en el binario Go."
version: 0.5.2
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit), tracking del tiempo por columna, adjuntos de archivos por card, notificaciones realtime (SSE) y modulos externos (Jira). Frontend Vite + React + Mantine v9 embebido en el binario Go. Endpoint MCP Streamable HTTP en /mcp."
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
uses_functions:
- random_hex_id_go_core
@@ -35,11 +36,56 @@ uses_functions:
- color_border_ts_ui
- color_swatch_ts_ui
- fetch_json_ts_infra
- claude_stream_go_core
- mcp_server_stdio_go_infra
- mcp_server_http_go_infra
- ws_upgrader_go_infra
uses_types:
- DurationStats_go_datascience
framework: "net/http + vite + react + mantine + dnd-kit"
entry_point: "backend/main.go"
dir_path: "apps/kanban"
service:
port: 8095
health_endpoint: /api/board
health_timeout_s: 3
systemd_unit: kanban.service
systemd_scope: user
restart_policy: always
runtime: systemd-user
pc_targets:
- aurgi-pc
is_local_only: false
# Validacion end-to-end (fase 4 del bucle reactivo). Ver issue 0068.
e2e_checks:
- id: build_frontend
cmd: "cd frontend && pnpm install --frozen-lockfile && pnpm build"
timeout_s: 180
expect_exit: 0
- id: build_backend
cmd: "CGO_ENABLED=1 go build -tags fts5 -o kanban ."
timeout_s: 120
expect_exit: 0
- id: migrations_apply
cmd: "rm -f /tmp/kanban_e2e.db && ./kanban --port 0 --db /tmp/kanban_e2e.db --migrate-only"
timeout_s: 15
expect_exit: 0
- id: migrations_schema
cmd: "sqlite3 /tmp/kanban_e2e.db 'SELECT version FROM schema_migrations ORDER BY version;'"
expect_stdout_contains: "1"
- id: smoke_api
cmd: "./kanban --port 8195 --db /tmp/kanban_e2e.db &"
health: "http://127.0.0.1:8195/api/board"
timeout_s: 10
- id: tests_go
cmd: "go test -tags fts5 -count=1 ./..."
timeout_s: 120
expect_exit: 0
- id: smoke_files
cmd: "bash e2e/files_smoke.sh"
timeout_s: 30
expect_exit: 0
---
## Arquitectura
@@ -50,7 +96,7 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
./kanban --port 8095 --db kanban.db
```
### Schema SQLite (`migrations/001_init.sql`)
### Schema SQLite (`migrations/001_init.sql` … `010_card_messages.sql`)
- **columns** — id, name, position, created_at
- **cards** — id, title, description, column_id (FK), position, created_at, updated_at
@@ -58,6 +104,7 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
- Una entrada con `exited_at IS NULL` = posicion actual
- Al mover una tarjeta a otra columna: cierra la entrada activa (`exited_at = now`) e inserta una nueva
- El borrado de tarjeta hace CASCADE sobre el historial
- **card_messages** (migration 010) — id, card_id (FK CASCADE), author_id (nullable), body, created_at. Comentarios humano-a-humano por card; distintos de `card_events` (sistema) y `/api/chat` (LLM global).
### API REST
@@ -72,7 +119,21 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
| PATCH | `/api/cards/{id}` | `{title?, description?}` |
| DELETE | `/api/cards/{id}` | — |
| POST | `/api/cards/{id}/move` | `{column_id, ordered_ids: [...]}` |
| POST | `/api/cards/{id}/duplicate` | — (clona la card en la misma columna al final; copia titulo+" (copia)", descripcion, color, requester, assignee, tags, stickers, deadline; NO copia historial ni mensajes) |
| GET | `/api/cards/{id}/messages` | — (lista de comentarios humano-a-humano de la card) |
| POST | `/api/cards/{id}/messages` | `{body}` (crea comentario; author = usuario de la sesion) |
| DELETE | `/api/cards/{cid}/messages/{mid}` | — (solo el autor puede borrar su mensaje) |
| GET | `/api/cards/{id}/history` | — (timeline con duraciones por columna) |
| GET | `/api/flags` | — (retorna `{ <name>: bool }` con los feature flags efectivos en esta instancia) |
| POST | `/api/auth/register` | `{username, password, display_name?}` (devuelve 403 `registration_disabled` si el flag `registration-enabled` esta en `false`) |
### Feature flags
`dev/feature_flags.json` (lado del repo) define los flags por instancia. Se cargan al arrancar (override con `--flags <path>`); fichero ausente equivale a "todos los flags en `false`". El endpoint `GET /api/flags` expone el estado actual para que el frontend oculte UI condicional (ej. el toggle de "Registrate" en `LoginPage` solo aparece cuando `registration-enabled` es `true`).
| Flag | Default | Efecto cuando esta en `true` |
|---|---|---|
| `registration-enabled` | `false` | Permite crear cuentas nuevas via `POST /api/auth/register` y muestra el toggle "Registrate" en la pantalla de login. |
### Frontend
@@ -81,6 +142,18 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
- **Modales** con `@mantine/modals` (confirmacion borrado, history timeline).
- Time-in-column live: `time_in_column_ms` del backend + tick local cada segundo para que el badge se actualice sin reload.
- DnD con `closestCorners` + `DragOverlay` para feedback visual al arrastrar.
- **Auto-refresh:** el board se recarga cada 30s (`api.getBoard`) sin interaccion del usuario; equivalente a pulsar el boton de refresco. El tick de 1s del time-in-column es independiente y no toca red.
- **Modal de card en dos columnas** (`CardEditPanel`): izquierda mantiene `CardForm` (titulo, solicitante, descripcion, asignacion, tags); derecha es un `Tabs` con `Chat` (por defecto) | `Enlaces` | `Archivos` (proximamente). Tamaño del modal: 85% del viewport.
- **Chat per-card** (`CardChatPanel`): lista de comentarios humano-a-humano persistidos en `card_messages`. Enter envia, Shift+Enter salto de linea. Solo el autor puede borrar su propio mensaje.
- **Enlaces** (`CardLinksPanel`): extrae URLs (`https?://...`) de titulo, descripcion y cuerpo de cada mensaje del chat. Deduplica, muestra hostname + URL completa + badge de origen. Click abre en pestaña nueva (`target="_blank"`).
- **Duplicar card:** click derecho sobre la card abre el menu contextual (mismo que el boton `⋮`), donde aparece el item "Duplicar". Al pulsarlo invoca `POST /api/cards/{id}/duplicate`. La copia se inserta al final de la misma columna con titulo + " (copia)".
- **Sesion obligatoria para chat:** `POST/DELETE /api/cards/{id}/messages` exige sesion activa (401 si falta). `author_id` siempre poblado; no hay comentarios anonimos.
- **Archivos** (`CardFilesPanel`): adjuntos por card almacenados en `apps/kanban/uploads/<card_id>/<random>__<safe_filename>` (filesystem, gitignored). Tabla `card_files` con soft-delete. Limite 10 MB por archivo. Tres vias de upload:
1. Drag&drop en el editor de descripcion (`CardForm`) → inserta `![name](url)` (imagen) o `[name](url)` (resto) en la posicion del cursor.
2. Drag&drop o boton paperclip en el chat (`CardChatPanel`) → crea un mensaje cuyo cuerpo es la ref markdown.
3. Boton "Subir" en el tab Archivos → sube sin embed.
- El renderer de mensajes (`MessageBody`) reconoce `![alt](url)` -> `<Image>` thumb 220px y `[name](url)` -> `<Anchor>`. Texto plano se renderiza con `whiteSpace: pre-wrap`.
- Endpoints: `POST /api/cards/{id}/files` (multipart, 10 MB max), `GET /api/cards/{id}/files`, `GET /api/files/{id}` (sirve binario con `inline` o `attachment` segun MIME), `DELETE /api/files/{id}` (soft delete).
### Build
@@ -109,3 +182,19 @@ cd frontend && pnpm dev
- IDs de columnas y tarjetas: 16 chars hex (8 bytes random) via `random_hex_id_go_core`.
- El historial conserva la cronologia exacta — incluso despues de cerrar y reabrir el server, los tiempos vivos siguen contando desde `entered_at`.
- El borrado de columna hace CASCADE: las tarjetas se borran y su historial tambien. Si se quiere preservar el historial al borrar, deberia archivarse en lugar de borrar.
## Capability growth log
Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
- `major`: breaking observable (CLI args, schema BBDD propia, formato wire).
- `minor`: feature aditiva (nuevo panel, endpoint, opcion).
- `patch`: bugfix sin cambio observable.
- v0.1.0 (2026-05-18) — baseline.
- v0.2.0 (2026-05-27) — adjuntos de archivos por card (issue 0128): tabla `card_files` con soft-delete, endpoints REST (`POST/GET/DELETE /api/cards/{id}/files`, `GET/DELETE /api/files/{id}`), tres vias de upload (drag&drop en descripcion y chat, boton en tab Archivos), render inline de imagenes via `MessageBody`. Limite 10 MB.
- v0.3.1 (2026-05-21) — patch: debounce board.invalidated (300ms trailing) + autoClose 4s en toasts de notification.created. Fix de blow-up de memoria en navegador por ráfagas de SSE.
- v0.4.0 (2026-05-22) — minor: endpoint MCP Streamable HTTP `/mcp` con per-user bearer tokens (tabla `mcp_tokens`, migration 017). Modal "MCP tokens" en avatar menu para generar/listar/revocar. Vite proxy enruta `/mcp` a WSL. Usa nueva funcion `mcp_server_http_go_infra`. Doc en `docs/MCP.md`.
- v0.5.2 (2026-06-01) — patch: el alta a Jira rellena el campo obligatorio "Área Solicitante" (`customfield_10158`) que el issue type Epic (y Mejora) del proyecto DATA exige en la pantalla de creacion. Sin esto, el `card.created` del 0.5.1 daba HTTP 400 "Solicitante is required". Nuevos campos en `jiraConfig`: `requester_field`, `requester_map`, `requester_default`. `create()`/`update()` inyectan el campo como single-select `{value:<opcion>}` resuelto desde el requester de la card (mapa case-insensitive) o el default. Como los requesters del kanban son nombres de persona (no departamentos), las cards caen al default (`Transformación`). `seed-jira-data` gana flags `--requester-field`/`--requester-default` y la rama de update ahora mergea config para no pisar ediciones de UI.
- v0.5.1 (2026-06-01) — patch: `handleCreateCard` ahora emite el evento `card.created` (antes solo `board.invalidated`, que no estaba en el filtro del modulo). Con esto la creacion de una card dispara `jiraHandler.create` y sincroniza el alta a Jira, igual que ya ocurria con move (`card.moved`) y chat (`message.created`). El evento se emite tras aplicar assignee/tags para que el issue de Jira los lleve.
- v0.5.0 (2026-05-27) — minor: merge ramas notifications-realtime + modules con master post-files. Trae notificaciones SSE (tabla `notifications`, migration 015), modulos externos para sincronizacion bidireccional (Jira, etc., tabla `modules`, migration 016), tokens MCP per-user (migration 017). Conserva files attachments del 0128. Renumeradas migrations notif 014/015/016 -> 015/016/017.
+5 -1
View File
@@ -30,8 +30,12 @@ func tokenFromRequest(r *http.Request) string {
}
// POST /api/auth/register {username, password, display_name?}
func handleRegister(db *DB) http.HandlerFunc {
func handleRegister(db *DB, flags *FeatureFlags) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !flags.Enabled("registration-enabled") {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusForbidden, Code: "registration_disabled", Message: "user registration is disabled on this instance"})
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"strings"
"time"
)
// backfillCandidate is the minimal projection we read from the cards table
// for the backfill loop. Avoids loading the full Card row + history we do not
// need.
type backfillCandidate struct {
ID string
Title string
ColumnID string
ColumnName string
}
// runBackfillJira walks every active kanban card that is not yet linked to a
// Jira issue and creates one via the configured jira module's handler. It is
// the only sanctioned way to backport existing kanban cards into Jira because
// the event dispatcher only ever fires on NEW kanban mutations.
//
// Batching: cards are processed in groups of --batch-size with a
// --pause-sec sleep between groups so we stay well below Jira's REST quota.
// --limit caps the total number of cards processed (0 = no cap).
// --column restricts to a single kanban column by name (case-insensitive).
// --dry-run lists the candidates without calling Jira.
func runBackfillJira(args []string) error {
fs := flag.NewFlagSet("kanban backfill-jira", flag.ContinueOnError)
dbPath := fs.String("db", "operations.db", "SQLite database path")
batchSize := fs.Int("batch-size", 10, "Cards processed per batch before pausing")
pauseSec := fs.Int("pause-sec", 5, "Seconds to sleep between batches")
limit := fs.Int("limit", 0, "Maximum cards to process total (0 = no limit)")
columnFilter := fs.String("column", "", "Only backfill cards whose column name matches (case-insensitive)")
dryRun := fs.Bool("dry-run", false, "List candidates and exit without calling Jira")
if err := fs.Parse(args); err != nil {
return err
}
if *batchSize <= 0 {
return fmt.Errorf("--batch-size must be > 0")
}
db, err := openDB(*dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
mod, cfg, err := activeJiraModule(db)
if err != nil {
return fmt.Errorf("module config: %w", err)
}
candidates, err := listBackfillCandidates(db, *columnFilter, *limit)
if err != nil {
return err
}
if len(candidates) == 0 {
fmt.Println("no cards to backfill (all active cards have jira_key set or filter is empty)")
return nil
}
fmt.Printf("backfill plan: %d cards across columns; batch=%d pause=%ds dry_run=%v\n",
len(candidates), *batchSize, *pauseSec, *dryRun)
fmt.Printf("module: %q (project=%s, board=%d, issue_type=%q)\n",
mod.Name, cfg.ProjectKey, cfg.BoardID, cfg.IssueType)
fmt.Println()
if *dryRun {
printCandidates(candidates)
return nil
}
h := &jiraHandler{}
var ok, failed int
for i := 0; i < len(candidates); i++ {
c := candidates[i]
if i > 0 && i%*batchSize == 0 {
fmt.Printf("--- batch boundary (%d/%d) — sleeping %ds ---\n", i, len(candidates), *pauseSec)
time.Sleep(time.Duration(*pauseSec) * time.Second)
}
status, err := h.Handle(context.Background(), db, mod, Event{
Type: "card.created",
CardID: c.ID,
})
now := time.Now().UTC().Format(time.RFC3339)
if err != nil {
failed++
_ = db.updateCardJiraSync(c.ID, "", now, err.Error())
fmt.Printf("[%4d/%4d] FAIL %-40s http=%d err=%s\n",
i+1, len(candidates), truncateInline(c.Title, 40), status, truncateInline(err.Error(), 80))
continue
}
ok++
statusName := cfg.StatusMap[c.ColumnName]
_ = db.updateCardJiraSync(c.ID, statusName, now, "")
// After Handle() the card row now carries the assigned jira_key.
linked, _ := db.lookupCardJiraKey(c.ID)
fmt.Printf("[%4d/%4d] OK %-40s -> %-12s status=%s\n",
i+1, len(candidates), truncateInline(c.Title, 40), linked, statusName)
}
fmt.Println()
fmt.Printf("done: %d ok · %d failed · %d total\n", ok, failed, len(candidates))
if failed > 0 {
return fmt.Errorf("%d cards failed to backfill", failed)
}
return nil
}
// listBackfillCandidates returns active (not deleted, not archived) cards
// without a Jira link, optionally filtered by column name. Newest-first so
// recent cards are mirrored first.
func listBackfillCandidates(db *DB, columnFilter string, limit int) ([]backfillCandidate, error) {
q := `
SELECT c.id, c.title, c.column_id, col.name
FROM cards c
JOIN columns col ON col.id = c.column_id
WHERE (c.jira_key IS NULL OR c.jira_key = '')
AND c.deleted_at IS NULL
AND c.archived_at IS NULL
`
args := []interface{}{}
if strings.TrimSpace(columnFilter) != "" {
q += " AND LOWER(col.name) = LOWER(?) "
args = append(args, columnFilter)
}
q += " ORDER BY c.created_at ASC "
if limit > 0 {
q += " LIMIT ? "
args = append(args, limit)
}
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("list candidates: %w", err)
}
defer rows.Close()
out := []backfillCandidate{}
for rows.Next() {
var c backfillCandidate
if err := rows.Scan(&c.ID, &c.Title, &c.ColumnID, &c.ColumnName); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// lookupCardJiraKey returns the jira_key column for a card, or empty string.
// Used to print the freshly-assigned key in the progress log.
func (db *DB) lookupCardJiraKey(cardID string) (string, error) {
var k sql.NullString
err := db.conn.QueryRow(`SELECT jira_key FROM cards WHERE id = ?`, cardID).Scan(&k)
if err != nil {
return "", err
}
if !k.Valid {
return "", nil
}
return k.String, nil
}
func printCandidates(cs []backfillCandidate) {
byCol := map[string]int{}
for _, c := range cs {
byCol[c.ColumnName]++
}
fmt.Println("candidates by column:")
for col, n := range byCol {
fmt.Printf(" %-25s %d\n", col, n)
}
fmt.Println()
fmt.Printf("first 20 of %d:\n", len(cs))
for i, c := range cs {
if i >= 20 {
break
}
fmt.Printf(" %-12s [%s] %s\n", c.ID, c.ColumnName, truncateInline(c.Title, 60))
}
}
func truncateInline(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}
+197 -255
View File
@@ -1,64 +1,46 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os/exec"
"os"
"path/filepath"
"strings"
"time"
"fn-registry/functions/core"
"fn-registry/functions/infra"
"nhooyr.io/websocket"
)
const chatSystemPrompt = `Eres el asistente del tablero kanban. Tu trabajo es responder al usuario y, cuando pida cambios, modificar el tablero llamando a tools.
const chatSystemPrompt = `Asistente del tablero kanban. Modifica el tablero llamando a tools MCP cuando el usuario pida cambios. Responde texto en markdown cuando solo informe.
Cuando necesites modificar el tablero, responde EXCLUSIVAMENTE con un bloque <actions>...</actions> que contenga JSON valido (un array de acciones). Sin texto antes ni despues.
Tools (MCP server "kanban"):
- Lectura: list_board, find_cards, card_history, list_users
- Columnas: create_column, update_column, delete_column, reorder_columns
- Tarjetas: create_card, update_card, delete_card, move_card, assign_card
Ejemplo:
<actions>
[
{"tool": "create_card", "input": {"column_id": "abc123", "requester": "Lucas", "title": "Revisar PR", "description": ""}},
{"tool": "rename_column", "input": {"id": "def456", "name": "En curso"}}
]
</actions>
El estado actual del tablero viene en <board_state> al final del mensaje. Usa esos IDs directamente — NO llames list_board si ya tienes lo que necesitas. NUNCA inventes IDs.
Tools disponibles (todas con sus inputs):
- list_board {} -> {columns, cards}
- create_column {name}
- update_column {id, name?, location?, width?, wip_limit?, is_done?} // location: "board" | "sidebar". width: 200..800 px. wip_limit: max tarjetas (0 = sin limite). is_done: marca columna como terminal (cards dentro se cuentan como completadas para metricas y se muestran tachadas).
- delete_column {id}
- reorder_columns {ids:[...]}
- create_card {column_id, requester?, title, description?}
- update_card {id, requester?, title?, description?, color?, locked?, assignee_id?} // color: "blue", "teal", "violet", "pink", "orange", "green", "yellow", "red", "" (default). locked: true bloquea la tarjeta (no se puede mover entre columnas hasta desbloquear). assignee_id: ID del usuario asignado o null para desasignar.
- delete_card {id}
- move_card {id, column_id, ordered_ids?} // si omites ordered_ids la tarjeta se anade al final
- card_history {id}
- find_cards {query?, column_id?, requester?}
- list_users {} -> [{id, username, display_name}]
- assign_card {id, assignee_id} // alias rapido de update_card. assignee_id puede ser null para desasignar.
Cuando termines, responde texto natural sin mas llamadas — eso cierra la conversacion.`
Si el usuario solo conversa o pide informacion (sin pedir cambios), responde texto natural en markdown SIN bloque <actions>.
const claudeTimeout = 300 * time.Second
Para resolver IDs a partir de nombres, mira el board_state que viene al final del prompt del usuario. NO inventes IDs.
func claudeBinary() string {
if b := os.Getenv("KANBAN_CLAUDE_BIN"); b != "" {
return b
}
return "claude"
}
LOOP ITERATIVO: Despues de aplicar tus acciones, el sistema te volvera a llamar con:
- Los resultados de las tool calls anteriores (incluyendo IDs reales de columnas/tarjetas creadas).
- El board_state actualizado.
- Tu mensaje de usuario original.
Cuando recibas resultados de iteraciones anteriores, USA LOS IDs REALES devueltos en lugar de inventar placeholders. Continua emitiendo mas <actions> hasta completar la tarea.
Cuando hayas terminado COMPLETAMENTE la tarea, responde texto natural (markdown) SIN bloque <actions> — eso señala el fin del loop.`
const claudeBin = "claude"
const claudeModel = "claude-sonnet-4-6"
const claudeTimeout = 120 * time.Second
const maxChatIterations = 8
func claudeModel() string {
if m := os.Getenv("KANBAN_CLAUDE_MODEL"); m != "" {
return m
}
return "claude-haiku-4-5-20251001"
}
type chatMessage struct {
Role string `json:"role"`
@@ -69,83 +51,168 @@ type chatRequest struct {
Messages []chatMessage `json:"messages"`
}
type chatResponse struct {
Role string `json:"role"`
Content string `json:"content"`
BoardChanged bool `json:"board_changed"`
ToolCalls []toolCallInfo `json:"tool_calls,omitempty"`
// wsEvent is the envelope sent to the browser. Type discriminates the payload.
type wsEvent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ToolID string `json:"tool_id,omitempty"`
Tool string `json:"tool,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Result string `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
BoardChanged bool `json:"board_changed,omitempty"`
Error string `json:"error,omitempty"`
}
type toolCallInfo struct {
Tool string `json:"tool"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Iteration int `json:"iteration,omitempty"`
// Result is included only for the loop's internal feedback to claude;
// it is omitted from the JSON response sent to the frontend (clients
// can use board_changed + reload to fetch fresh state).
Result any `json:"-"`
}
type claudeJSONResult struct {
Type string `json:"type"`
IsError bool `json:"is_error"`
Result string `json:"result"`
StopReason string `json:"stop_reason"`
}
// runClaude invokes the `claude` CLI in print mode with the given system prompt
// and user message. The board JSON is appended to the user message under a
// `board_state` marker so the assistant can resolve names to IDs.
// handleChatWS upgrades the request to WebSocket and streams claude events.
//
// stdin: the user-facing prompt (history flattened).
// returns: assistant's text reply.
func runClaude(ctx context.Context, systemPrompt, userInput, boardJSON, workdir string) (string, error) {
if _, err := exec.LookPath(claudeBin); err != nil {
return "", errors.New("claude CLI not found in PATH")
// Wire protocol:
// client → server (one message): { "messages": [{role, content}, ...] }
// server → client (many): wsEvent ndjson-style messages
// types: "delta" (assistant text), "tool_use", "tool_result", "result", "error"
// server closes connection at end.
func handleChatWS(db *DB, workdir string, logger *ChatLogger, internalToken string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
conn, err := infra.WSUpgrader(w, r, []string{"*"})
if err != nil {
return
}
defer conn.Close(websocket.StatusInternalError, "internal")
ctx, cancel := context.WithTimeout(r.Context(), claudeTimeout)
defer cancel()
// Read the initial chat request.
_, raw, err := conn.Read(ctx)
if err != nil {
return
}
var req chatRequest
if err := json.Unmarshal(raw, &req); err != nil {
sendWS(ctx, conn, wsEvent{Type: "error", Error: "invalid chat request: " + err.Error()})
return
}
if len(req.Messages) == 0 {
sendWS(ctx, conn, wsEvent{Type: "error", Error: "messages required"})
return
}
boardChanged, err := streamChat(ctx, conn, db, workdir, internalToken, req.Messages, logger)
if err != nil {
sendWS(ctx, conn, wsEvent{Type: "error", Error: err.Error()})
return
}
sendWS(ctx, conn, wsEvent{Type: "done", BoardChanged: boardChanged})
conn.Close(websocket.StatusNormalClosure, "")
}
ctx, cancel := context.WithTimeout(ctx, claudeTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, claudeBin,
"-p",
"--model", claudeModel,
"--output-format", "json",
"--no-session-persistence",
"--tools", "",
"--system-prompt", systemPrompt,
)
cmd.Dir = workdir
prompt := userInput
if boardJSON != "" {
prompt += "\n\n<board_state>\n" + boardJSON + "\n</board_state>\n"
}
cmd.Stdin = bytes.NewBufferString(prompt)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("claude exec: %w (stderr: %s)", err, stderr.String())
}
var res claudeJSONResult
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
return "", fmt.Errorf("parse claude json: %w (raw: %s)", err, stdout.String())
}
if res.IsError {
return "", fmt.Errorf("claude error: %s", res.Result)
}
return res.Result, nil
}
// flattenMessages converts a chat history into a single text prompt for `claude -p`.
// Format: lines of `Usuario: ...` / `Asistente: ...`. Last user message ends the prompt.
func streamChat(ctx context.Context, conn *websocket.Conn, db *DB, workdir, token string, msgs []chatMessage, logger *ChatLogger) (bool, error) {
binPath, err := os.Executable()
if err != nil {
return false, fmt.Errorf("locate kanban binary: %w", err)
}
// Backend URL: trust X-Forwarded or fall back to localhost (kanban listens
// on its main port). The MCP subprocess hits the loopback interface.
backendURL := os.Getenv("KANBAN_PUBLIC_URL")
if backendURL == "" {
port := os.Getenv("KANBAN_LISTEN_PORT")
if port == "" {
port = "8095"
}
backendURL = "http://127.0.0.1:" + port
}
mcpPath, err := writeMCPConfig(binPath, backendURL, token)
if err != nil {
return false, fmt.Errorf("write mcp config: %w", err)
}
defer os.Remove(mcpPath)
prompt := flattenMessages(msgs)
if board, err := boardSnapshot(db); err == nil && board != "" {
prompt += "\n\n<board_state>\n" + board + "\n</board_state>\n"
}
stdin := strings.NewReader(prompt)
events, err := core.StreamClaude(ctx, core.ClaudeStreamOpts{
Bin: claudeBinary(),
Args: []string{
"--model", claudeModel(),
"--no-session-persistence",
"--mcp-config", mcpPath,
"--strict-mcp-config",
"--system-prompt", chatSystemPrompt,
"--allowedTools",
"mcp__kanban__list_board,mcp__kanban__create_column,mcp__kanban__update_column,mcp__kanban__rename_column,mcp__kanban__delete_column,mcp__kanban__reorder_columns,mcp__kanban__create_card,mcp__kanban__update_card,mcp__kanban__delete_card,mcp__kanban__move_card,mcp__kanban__card_history,mcp__kanban__find_cards,mcp__kanban__list_users,mcp__kanban__assign_card",
},
Stdin: stdin,
Workdir: workdir,
})
if err != nil {
return false, fmt.Errorf("spawn claude: %w", err)
}
boardChanged := false
for ev := range events {
switch ev.Type {
case core.ClaudeEventTextDelta:
sendWS(ctx, conn, wsEvent{Type: "delta", Text: ev.Text})
case core.ClaudeEventToolUse:
toolName := stripMCPPrefix(ev.ToolName)
sendWS(ctx, conn, wsEvent{
Type: "tool_use",
ToolID: ev.ToolUseID,
Tool: toolName,
Input: ev.ToolInput,
})
if toolMutates(toolName) {
boardChanged = true
}
case core.ClaudeEventToolResult:
sendWS(ctx, conn, wsEvent{
Type: "tool_result",
ToolID: ev.ToolResultID,
Result: ev.ToolResultContent,
IsError: ev.ToolResultIsError,
})
case core.ClaudeEventResult:
sendWS(ctx, conn, wsEvent{
Type: "result",
Text: ev.Result,
IsError: ev.IsError,
})
case core.ClaudeEventError:
sendWS(ctx, conn, wsEvent{Type: "error", Error: ev.Error})
}
}
return boardChanged, nil
}
// stripMCPPrefix removes the "mcp__<server>__" prefix added by claude when
// tools come from an MCP server, leaving the bare tool name.
func stripMCPPrefix(name string) string {
const pre = "mcp__kanban__"
if strings.HasPrefix(name, pre) {
return name[len(pre):]
}
return name
}
func sendWS(ctx context.Context, conn *websocket.Conn, ev wsEvent) {
b, err := json.Marshal(ev)
if err != nil {
return
}
wctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_ = conn.Write(wctx, websocket.MessageText, b)
}
// flattenMessages converts chat history into a single prompt for `claude -p`.
func flattenMessages(msgs []chatMessage) string {
var b bytes.Buffer
var b strings.Builder
for _, m := range msgs {
role := "Usuario"
if m.Role == "assistant" {
@@ -159,100 +226,8 @@ func flattenMessages(msgs []chatMessage) string {
return b.String()
}
func handleChat(db *DB, workdir string, logger *ChatLogger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req chatRequest
if err := infra.HTTPParseBody(r, &req, 1<<20); err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()})
return
}
if len(req.Messages) == 0 {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: "messages required"})
return
}
baseUserInput := flattenMessages(req.Messages)
allCalls := []toolCallInfo{}
var finalText string
boardChanged := false
for iter := 1; iter <= maxChatIterations; iter++ {
boardJSON, err := boardSnapshot(db)
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "internal", Message: err.Error()})
return
}
prompt := buildIterationPrompt(baseUserInput, allCalls, iter)
assistantText, err := runClaude(r.Context(), chatSystemPrompt, prompt, boardJSON, workdir)
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "claude_error", Message: err.Error()})
return
}
actionsJSON, stripped, found := extractActions(assistantText)
if !found {
finalText = assistantText
break
}
calls, changed := applyActions(db, actionsJSON, logger)
for i := range calls {
calls[i].Iteration = iter
}
allCalls = append(allCalls, calls...)
if changed {
boardChanged = true
}
finalText = stripped // tentative; overwritten if next iter responds free text
if iter == maxChatIterations {
finalText = strings.TrimSpace(stripped + "\n\n_Limite de iteraciones alcanzado._")
break
}
}
// Strip Result fields before serializing (not exported but defensive).
respCalls := make([]toolCallInfo, len(allCalls))
for i, c := range allCalls {
respCalls[i] = toolCallInfo{Tool: c.Tool, OK: c.OK, Error: c.Error, Iteration: c.Iteration}
}
resp := chatResponse{
Role: "assistant",
Content: finalText,
ToolCalls: respCalls,
BoardChanged: boardChanged,
}
if resp.Content == "" {
resp.Content = summarizeCalls(respCalls)
}
infra.HTTPJSONResponse(w, http.StatusOK, resp)
}
}
// buildIterationPrompt composes the user prompt for iteration N.
// Iteration 1 = original user input; later iterations also include a summary
// of previous tool calls so the assistant can use real IDs.
func buildIterationPrompt(baseUserInput string, prevCalls []toolCallInfo, iter int) string {
if iter == 1 || len(prevCalls) == 0 {
return baseUserInput
}
var b bytes.Buffer
b.WriteString(baseUserInput)
b.WriteString("\n[Resultados de iteraciones anteriores]\n")
for _, c := range prevCalls {
if c.OK {
summary := summarizeResult(c.Result)
fmt.Fprintf(&b, "- iter %d %s: ok %s\n", c.Iteration, c.Tool, summary)
} else {
fmt.Fprintf(&b, "- iter %d %s: ERROR %s\n", c.Iteration, c.Tool, c.Error)
}
}
fmt.Fprintf(&b, "\n[Iteracion %d] Continua con las acciones pendientes. Si terminaste, responde texto natural sin <actions>.\n", iter)
return b.String()
}
// boardSnapshot returns a JSON dump of columns + cards to inject in the
// initial prompt, saving a list_board round-trip.
func boardSnapshot(db *DB) (string, error) {
cols, err := db.ListColumns()
if err != nil {
@@ -262,62 +237,14 @@ func boardSnapshot(db *DB) (string, error) {
if err != nil {
return "", err
}
b, err := json.MarshalIndent(map[string]any{"columns": cols, "cards": cards}, "", " ")
b, err := json.Marshal(map[string]any{"columns": cols, "cards": cards})
if err != nil {
return "", err
}
return string(b), nil
}
func applyActions(db *DB, actionsJSON string, logger *ChatLogger) ([]toolCallInfo, bool) {
var actions []struct {
Tool string `json:"tool"`
Input json.RawMessage `json:"input"`
}
if err := json.Unmarshal([]byte(actionsJSON), &actions); err != nil {
return []toolCallInfo{{Tool: "<parse>", OK: false, Error: err.Error()}}, false
}
results := make([]toolCallInfo, 0, len(actions))
changed := false
for _, a := range actions {
if err := validateToolName(a.Tool); err != nil {
info := toolCallInfo{Tool: a.Tool, OK: false, Error: err.Error()}
results = append(results, info)
logger.Log(a.Tool, a.Input, ToolResult{OK: false, Error: err.Error()})
continue
}
res := executeTool(db, a.Tool, a.Input)
logger.Log(a.Tool, a.Input, res)
info := toolCallInfo{Tool: a.Tool, OK: res.OK, Result: res.Result}
if !res.OK {
info.Error = res.Error
} else if toolMutates(a.Tool) {
changed = true
}
results = append(results, info)
}
return results, changed
}
func summarizeCalls(calls []toolCallInfo) string {
if len(calls) == 0 {
return ""
}
var b bytes.Buffer
b.WriteString("Acciones aplicadas:\n")
for _, c := range calls {
if c.OK {
fmt.Fprintf(&b, "- %s: ok\n", c.Tool)
} else {
fmt.Fprintf(&b, "- %s: error (%s)\n", c.Tool, c.Error)
}
}
return b.String()
}
// chatWorkdir resolves an absolute working directory for `claude -p` (avoids
// inheriting CLAUDE.md from parent directories with unrelated context).
// chatWorkdir resolves an absolute working directory for `claude -p`.
func chatWorkdir(dbPath string) string {
abs, err := filepath.Abs(dbPath)
if err != nil {
@@ -325,3 +252,18 @@ func chatWorkdir(dbPath string) string {
}
return filepath.Dir(abs)
}
// --- Legacy handleChat retained as a thin shim that returns 410 Gone. -------
// Kept so existing clients see a clear error instead of a 404 while they
// migrate to the WebSocket endpoint.
func handleChat(_ *DB, _ string, _ *ChatLogger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
infra.HTTPErrorResponse(w, infra.HTTPError{
Status: http.StatusGone,
Code: "deprecated",
Message: "POST /api/chat removed; use WebSocket at /api/chat/ws",
})
}
}
+296
View File
@@ -0,0 +1,296 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"nhooyr.io/websocket"
)
// fakeClaudeScript writes a bash script that emits NDJSON stream-json events
// to stdout and exits 0. Returns the absolute path of the script.
func fakeClaudeScript(t *testing.T, payload string) string {
t.Helper()
if _, err := os.Stat("/bin/bash"); err != nil {
t.Skip("/bin/bash not available")
}
dir := t.TempDir()
path := filepath.Join(dir, "claude")
body := "#!/bin/bash\nset -e\ncat <<'__EOF__'\n" + payload + "\n__EOF__\n"
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
t.Fatalf("write fake claude: %v", err)
}
return path
}
// chatWSTestServer wires the WebSocket chat handler in front of a test DB.
func chatWSTestServer(t *testing.T) (*httptest.Server, *DB, string) {
t.Helper()
db := setupTestDB(t)
dir := t.TempDir()
logger := newChatLogger(filepath.Join(dir, "chat.log"))
token := generateInternalToken()
srv := httptest.NewServer(handleChatWS(db, dir, logger, token))
t.Cleanup(srv.Close)
return srv, db, token
}
func dialChatWS(t *testing.T, srv *httptest.Server) *websocket.Conn {
t.Helper()
u, _ := url.Parse(srv.URL)
wsURL := "ws://" + u.Host
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("dial %s: %v", wsURL, err)
}
return c
}
func readWSEvent(t *testing.T, conn *websocket.Conn) wsEvent {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, data, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read: %v", err)
}
var ev wsEvent
if err := json.Unmarshal(data, &ev); err != nil {
t.Fatalf("unmarshal %q: %v", string(data), err)
}
return ev
}
func sendInitial(t *testing.T, conn *websocket.Conn, msgs []chatMessage) {
t.Helper()
body, _ := json.Marshal(chatRequest{Messages: msgs})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := conn.Write(ctx, websocket.MessageText, body); err != nil {
t.Fatalf("write: %v", err)
}
}
// --- WS streaming tests ---------------------------------------------------
func TestChatWS_StreamsTextDelta(t *testing.T) {
payload := `{"type":"system","subtype":"init","session_id":"s1","model":"test"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hola "}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"mundo"}]}}
{"type":"result","subtype":"success","is_error":false,"result":"Hola mundo","stop_reason":"end_turn"}`
t.Setenv("KANBAN_CLAUDE_BIN", fakeClaudeScript(t, payload))
srv, _, _ := chatWSTestServer(t)
conn := dialChatWS(t, srv)
defer conn.Close(websocket.StatusNormalClosure, "")
sendInitial(t, conn, []chatMessage{{Role: "user", Content: "saluda"}})
var deltas []string
var sawResult, sawDone bool
for i := 0; i < 12 && !sawDone; i++ {
ev := readWSEvent(t, conn)
switch ev.Type {
case "delta":
deltas = append(deltas, ev.Text)
case "result":
sawResult = true
case "done":
sawDone = true
case "error":
t.Fatalf("unexpected error event: %s", ev.Error)
}
}
if !sawDone {
t.Fatalf("never received done event")
}
if !sawResult {
t.Fatalf("never received result event")
}
if got := strings.Join(deltas, ""); got != "Hola mundo" {
t.Fatalf("expected 'Hola mundo' from deltas, got %q", got)
}
}
func TestChatWS_StreamsToolUseAndResult(t *testing.T) {
payload := `{"type":"system","subtype":"init"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"mcp__kanban__create_column","input":{"name":"Backlog"}}]}}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"{\"ok\":true,\"result\":{\"id\":\"col_x\"}}","is_error":false}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Listo"}]}}
{"type":"result","subtype":"success","is_error":false,"result":"Listo","stop_reason":"end_turn"}`
t.Setenv("KANBAN_CLAUDE_BIN", fakeClaudeScript(t, payload))
srv, _, _ := chatWSTestServer(t)
conn := dialChatWS(t, srv)
defer conn.Close(websocket.StatusNormalClosure, "")
sendInitial(t, conn, []chatMessage{{Role: "user", Content: "crea Backlog"}})
var sawToolUse, sawToolResult, sawDelta, sawDone bool
var doneEv wsEvent
for i := 0; i < 16 && !sawDone; i++ {
ev := readWSEvent(t, conn)
switch ev.Type {
case "tool_use":
sawToolUse = true
if ev.Tool != "create_column" {
t.Errorf("tool name not stripped: %q", ev.Tool)
}
if !strings.Contains(string(ev.Input), "Backlog") {
t.Errorf("input missing Backlog: %s", ev.Input)
}
case "tool_result":
sawToolResult = true
if ev.IsError {
t.Errorf("tool_result is_error true")
}
case "delta":
sawDelta = true
case "done":
sawDone = true
doneEv = ev
case "error":
t.Fatalf("unexpected error: %s", ev.Error)
}
}
if !sawToolUse || !sawToolResult || !sawDelta || !sawDone {
t.Fatalf("missing events: tool_use=%v tool_result=%v delta=%v done=%v",
sawToolUse, sawToolResult, sawDelta, sawDone)
}
if !doneEv.BoardChanged {
t.Errorf("expected board_changed=true (create_column is a mutator)")
}
}
func TestChatWS_RejectsEmptyMessages(t *testing.T) {
t.Setenv("KANBAN_CLAUDE_BIN", fakeClaudeScript(t,
`{"type":"result","subtype":"success","is_error":false,"result":""}`))
srv, _, _ := chatWSTestServer(t)
conn := dialChatWS(t, srv)
defer conn.Close(websocket.StatusNormalClosure, "")
sendInitial(t, conn, []chatMessage{})
ev := readWSEvent(t, conn)
if ev.Type != "error" {
t.Fatalf("expected error event, got %+v", ev)
}
if !strings.Contains(ev.Error, "messages required") {
t.Fatalf("unexpected error: %s", ev.Error)
}
}
func TestChatWS_PropagatesClaudeFailure(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "claude")
body := "#!/bin/bash\necho 'broken' >&2\nexit 7\n"
if err := os.WriteFile(bin, []byte(body), 0o755); err != nil {
t.Fatalf("write: %v", err)
}
t.Setenv("KANBAN_CLAUDE_BIN", bin)
srv, _, _ := chatWSTestServer(t)
conn := dialChatWS(t, srv)
defer conn.Close(websocket.StatusNormalClosure, "")
sendInitial(t, conn, []chatMessage{{Role: "user", Content: "hola"}})
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
ev := readWSEvent(t, conn)
switch ev.Type {
case "error":
if !strings.Contains(ev.Error, "claude exit") {
t.Fatalf("expected claude exit error, got: %s", ev.Error)
}
return
case "done":
t.Fatalf("done received before error")
}
}
t.Fatalf("never received error event")
}
// --- /api/tool internal endpoint tests ------------------------------------
func internalToolServer(t *testing.T) (*httptest.Server, *DB, string) {
t.Helper()
db := setupTestDB(t)
logger := newChatLogger(filepath.Join(t.TempDir(), "log"))
token := generateInternalToken()
mux := http.NewServeMux()
mux.Handle("POST /api/tool/{name}", handleInternalTool(db, token, logger))
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, db, token
}
func TestInternalTool_CreateColumnRoundtrip(t *testing.T) {
srv, db, token := internalToolServer(t)
req, _ := http.NewRequest("POST", srv.URL+"/api/tool/create_column", strings.NewReader(`{"name":"Backlog"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, token)
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
var tr ToolResult
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
t.Fatalf("decode: %v", err)
}
if !tr.OK {
t.Fatalf("create_column failed: %s", tr.Error)
}
cols, err := db.ListColumns()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(cols) != 1 || cols[0].Name != "Backlog" {
t.Fatalf("expected 1 col Backlog, got %+v", cols)
}
}
func TestInternalTool_RejectsMissingToken(t *testing.T) {
srv, _, _ := internalToolServer(t)
req, _ := http.NewRequest("POST", srv.URL+"/api/tool/create_column", strings.NewReader(`{"name":"X"}`))
req.Header.Set("Content-Type", "application/json")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 401 {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestInternalTool_UnknownTool(t *testing.T) {
srv, _, token := internalToolServer(t)
req, _ := http.NewRequest("POST", srv.URL+"/api/tool/no_such", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, token)
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 404 {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
+71
View File
@@ -0,0 +1,71 @@
// seed_e2e_user creates or updates a deterministic test user for Playwright e2e.
// Usage: go run ./backend/cmd/seed_e2e_user --db apps/kanban/operations.db
//
// Idempotent: safe to run repeatedly. The user "e2e_user" / password "e2e_test_pw_2026"
// is intentional and used by apps/kanban/frontend/e2e/*.spec.ts when env vars are not set.
package main
import (
"database/sql"
"errors"
"flag"
"fmt"
"os"
"time"
_ "github.com/mattn/go-sqlite3"
"golang.org/x/crypto/bcrypt"
)
func main() {
dbPath := flag.String("db", "operations.db", "path to kanban operations.db")
username := flag.String("username", "e2e_user", "username")
password := flag.String("password", "e2e_test_pw_2026", "password")
displayName := flag.String("display", "E2E Test", "display name")
flag.Parse()
db, err := sql.Open("sqlite3", *dbPath)
if err != nil {
fail(err)
}
defer db.Close()
hash, err := bcrypt.GenerateFromPassword([]byte(*password), bcrypt.DefaultCost)
if err != nil {
fail(err)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
id := "e2etest" + fmt.Sprintf("%x", time.Now().UnixNano())[:9]
// Try update first
res, err := db.Exec(
`UPDATE users SET password_hash=?, display_name=? WHERE username=?`,
string(hash), *displayName, *username,
)
if err != nil {
fail(err)
}
n, _ := res.RowsAffected()
if n > 0 {
fmt.Printf("updated existing user %q\n", *username)
return
}
_, err = db.Exec(
`INSERT INTO users (id, username, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)`,
id, *username, string(hash), *displayName, now,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
fail(err)
}
fail(err)
}
fmt.Printf("created user %q (id=%s)\n", *username, id)
}
func fail(err error) {
fmt.Fprintln(os.Stderr, "seed_e2e_user:", err)
os.Exit(1)
}
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// Issue 0089: tiempo maximo por columna.
func openTestDB(t *testing.T) *DB {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
db, err := openDB(path)
if err != nil {
t.Fatalf("openDB: %v", err)
}
t.Cleanup(func() {
db.Close()
_ = os.Remove(path)
})
return db
}
func TestColumnMaxTimeMinutes_Defaults(t *testing.T) {
db := openTestDB(t)
c, err := db.CreateColumn("col1")
if err != nil {
t.Fatalf("CreateColumn: %v", err)
}
if c.MaxTimeMinutes != 0 {
t.Fatalf("new column max_time_minutes = %d, want 0", c.MaxTimeMinutes)
}
cols, err := db.ListColumns()
if err != nil {
t.Fatalf("ListColumns: %v", err)
}
if len(cols) == 0 || cols[0].MaxTimeMinutes != 0 {
t.Fatalf("listed col max_time_minutes = %d, want 0", cols[0].MaxTimeMinutes)
}
}
func TestColumnMaxTimeMinutes_Update(t *testing.T) {
db := openTestDB(t)
c, _ := db.CreateColumn("c")
v := 30
if err := db.UpdateColumn(c.ID, ColumnPatch{MaxTimeMinutes: &v}); err != nil {
t.Fatalf("UpdateColumn set 30: %v", err)
}
cols, _ := db.ListColumns()
if cols[0].MaxTimeMinutes != 30 {
t.Fatalf("after set max=30 got %d", cols[0].MaxTimeMinutes)
}
// Negative clamps to 0.
neg := -5
if err := db.UpdateColumn(c.ID, ColumnPatch{MaxTimeMinutes: &neg}); err != nil {
t.Fatalf("UpdateColumn neg: %v", err)
}
cols, _ = db.ListColumns()
if cols[0].MaxTimeMinutes != 0 {
t.Fatalf("negative should clamp to 0, got %d", cols[0].MaxTimeMinutes)
}
// Other fields untouched.
w := 555
if err := db.UpdateColumn(c.ID, ColumnPatch{Width: &w}); err != nil {
t.Fatalf("UpdateColumn width: %v", err)
}
cols, _ = db.ListColumns()
if cols[0].MaxTimeMinutes != 0 {
t.Fatalf("max_time should still be 0 after width update, got %d", cols[0].MaxTimeMinutes)
}
if cols[0].Width != 555 {
t.Fatalf("width = %d, want 555", cols[0].Width)
}
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// DailySummary persisted row.
type DailySummary struct {
Date string `json:"date"`
Summary string `json:"summary"`
Prompt string `json:"prompt"`
Model string `json:"model"`
GeneratedAt string `json:"generated_at"`
GeneratedBy *string `json:"generated_by"`
}
func (db *DB) GetDailySummary(date string) (*DailySummary, error) {
row := db.conn.QueryRow(`SELECT date, summary, prompt, model, generated_at, generated_by FROM daily_summaries WHERE date=?`, date)
var s DailySummary
var by sql.NullString
if err := row.Scan(&s.Date, &s.Summary, &s.Prompt, &s.Model, &s.GeneratedAt, &by); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
if by.Valid {
v := by.String
s.GeneratedBy = &v
}
return &s, nil
}
func (db *DB) UpsertDailySummary(s DailySummary) error {
var by any = nil
if s.GeneratedBy != nil {
by = *s.GeneratedBy
}
_, err := db.conn.Exec(`
INSERT INTO daily_summaries (date, summary, prompt, model, generated_at, generated_by)
VALUES (?,?,?,?,?,?)
ON CONFLICT(date) DO UPDATE SET
summary=excluded.summary,
prompt=excluded.prompt,
model=excluded.model,
generated_at=excluded.generated_at,
generated_by=excluded.generated_by
`, s.Date, s.Summary, s.Prompt, s.Model, s.GeneratedAt, by)
return err
}
func (db *DB) GetSetting(key string) (string, error) {
var v string
err := db.conn.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&v)
if err == sql.ErrNoRows {
return "", nil
}
return v, err
}
func (db *DB) SetSetting(key, value string, by *string) error {
now := nowRFC3339()
var byArg any = nil
if by != nil {
byArg = *by
}
_, err := db.conn.Exec(`
INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at, updated_by=excluded.updated_by
`, key, value, now, byArg)
return err
}
// runClaudePrompt executes `claude -p` with the given user prompt; returns
// stdout trimmed. Times out via claudeTimeout from chat.go.
func runClaudePrompt(ctx context.Context, prompt string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, claudeTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, claudeBinary(), "-p", "--model", claudeModel())
cmd.Stdin = strings.NewReader(prompt)
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("claude -p failed: %v: %s", err, strings.TrimSpace(errb.String()))
}
return strings.TrimSpace(out.String()), nil
}
// BuildDailySummaryPrompt composes the prompt for the LLM by interpolating the
// configurable instruction template with the JSON of the report.
func BuildDailySummaryPrompt(template string, report *DailyReport) (string, error) {
js, err := json.MarshalIndent(report, "", " ")
if err != nil {
return "", err
}
return fmt.Sprintf("%s\n\n<reporte_json>\n%s\n</reporte_json>\n", template, string(js)), nil
}
// GenerateDailySummary builds the prompt, calls Claude, persists and returns
// the resulting summary. actorID is optional (empty = anon/system).
func (db *DB) GenerateDailySummary(ctx context.Context, date, tz, actorID string) (*DailySummary, error) {
rep, err := db.DailyReportFor(date, tz)
if err != nil {
return nil, err
}
tmpl, err := db.GetSetting("daily_report_prompt")
if err != nil {
return nil, err
}
if tmpl == "" {
tmpl = "Resume el reporte diario en 4 frases cortas, en castellano, sin inventar datos."
}
prompt, err := BuildDailySummaryPrompt(tmpl, rep)
if err != nil {
return nil, err
}
summary, err := runClaudePrompt(ctx, prompt)
if err != nil {
return nil, err
}
rec := DailySummary{
Date: date,
Summary: summary,
Prompt: tmpl,
Model: claudeModel(),
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
if actorID != "" {
rec.GeneratedBy = &actorID
}
if err := db.UpsertDailySummary(rec); err != nil {
return nil, err
}
return &rec, nil
}
+288 -19
View File
@@ -17,14 +17,15 @@ import (
var migrationsFS embed.FS
type Column struct {
ID string `json:"id"`
Name string `json:"name"`
Position int `json:"position"`
Location string `json:"location"`
Width int `json:"width"`
WIPLimit int `json:"wip_limit"`
IsDone bool `json:"is_done"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
Name string `json:"name"`
Position int `json:"position"`
Location string `json:"location"`
Width int `json:"width"`
WIPLimit int `json:"wip_limit"`
IsDone bool `json:"is_done"`
MaxTimeMinutes int `json:"max_time_minutes"`
CreatedAt string `json:"created_at"`
}
type Sticker struct {
@@ -46,6 +47,7 @@ type Card struct {
AssigneeID *string `json:"assignee_id"`
CompletedAt *string `json:"completed_at"`
DeletedAt *string `json:"deleted_at"`
ArchivedAt *string `json:"archived_at"`
Tags []string `json:"tags"`
Stickers []Sticker `json:"stickers"`
Deadline *string `json:"deadline"`
@@ -305,7 +307,7 @@ func insertCardEvent(execer interface {
// --- Columns ---
func (db *DB) ListColumns() ([]Column, error) {
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, created_at FROM columns ORDER BY position, created_at`)
rows, err := db.conn.Query(`SELECT id, name, position, location, width, wip_limit, is_done, max_time_minutes, created_at FROM columns ORDER BY position, created_at`)
if err != nil {
return nil, err
}
@@ -314,7 +316,7 @@ func (db *DB) ListColumns() ([]Column, error) {
for rows.Next() {
var c Column
var isDone int
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.CreatedAt); err != nil {
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.WIPLimit, &isDone, &c.MaxTimeMinutes, &c.CreatedAt); err != nil {
return nil, err
}
c.IsDone = isDone != 0
@@ -344,12 +346,13 @@ func (db *DB) CreateColumn(name string) (*Column, error) {
}
type ColumnPatch struct {
Name *string
Position *int
Location *string
Width *int
WIPLimit *int
IsDone *bool
Name *string
Position *int
Location *string
Width *int
WIPLimit *int
IsDone *bool
MaxTimeMinutes *int
}
func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
@@ -411,6 +414,15 @@ func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
}
}
}
if patch.MaxTimeMinutes != nil {
m := *patch.MaxTimeMinutes
if m < 0 {
m = 0
}
if _, err := db.conn.Exec(`UPDATE columns SET max_time_minutes=? WHERE id=?`, m, id); err != nil {
return err
}
}
return nil
}
@@ -437,7 +449,7 @@ func (db *DB) ReorderColumns(ids []string) error {
func (db *DB) ListCardsWithTime() ([]Card, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at,
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at,
h.entered_at, l.locked_at,
COALESCE((
SELECT CAST(SUM((julianday(COALESCE(unlocked_at, ?)) - julianday(locked_at)) * 86400000) AS INTEGER)
@@ -448,7 +460,7 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
ON h.card_id = c.id AND h.exited_at IS NULL
LEFT JOIN card_lock_history l
ON l.card_id = c.id AND l.unlocked_at IS NULL
WHERE c.deleted_at IS NULL
WHERE c.deleted_at IS NULL AND c.archived_at IS NULL
ORDER BY c.column_id, c.position, c.created_at
`, time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
@@ -463,12 +475,13 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
var assignee sql.NullString
var completed sql.NullString
var deleted sql.NullString
var archived sql.NullString
var tagsJSON string
var stickersJSON string
var deadline sql.NullString
var lockedAt sql.NullString
var locked int
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &archived, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt, &entered, &lockedAt, &c.TotalLockedMs); err != nil {
return nil, err
}
c.Stickers = parseStickers(stickersJSON)
@@ -493,6 +506,10 @@ func (db *DB) ListCardsWithTime() ([]Card, error) {
s := deleted.String
c.DeletedAt = &s
}
if archived.Valid && archived.String != "" {
s := archived.String
c.ArchivedAt = &s
}
c.Tags = parseTags(tagsJSON)
if entered.Valid {
c.EnteredAt = entered.String
@@ -816,6 +833,90 @@ func (db *DB) ListDeletedCards() ([]Card, error) {
return out, rows.Err()
}
// ArchiveCard moves a card to the archive (out of the board, retrievable).
// Used both manually and by AutoArchiveDoneOlderThan.
func (db *DB) ArchiveCard(id string) error {
now := nowRFC3339()
_, err := db.conn.Exec(`UPDATE cards SET archived_at=?, updated_at=? WHERE id=? AND archived_at IS NULL AND deleted_at IS NULL`, now, now, id)
return err
}
// UnarchiveCard pulls a card out of the archive back into its column.
func (db *DB) UnarchiveCard(id string) error {
now := nowRFC3339()
_, err := db.conn.Exec(`UPDATE cards SET archived_at=NULL, updated_at=? WHERE id=? AND archived_at IS NOT NULL`, now, id)
return err
}
// AutoArchiveDoneOlderThan archives every card whose column is is_done=1 AND
// whose entered_at in that column is older than `older`. Idempotent: cards
// already archived or deleted are skipped. Returns the count affected.
func (db *DB) AutoArchiveDoneOlderThan(older time.Duration) (int64, error) {
cutoff := time.Now().UTC().Add(-older).Format(time.RFC3339Nano)
now := nowRFC3339()
res, err := db.conn.Exec(`
UPDATE cards SET archived_at=?, updated_at=?
WHERE archived_at IS NULL
AND deleted_at IS NULL
AND column_id IN (SELECT id FROM columns WHERE is_done=1)
AND id IN (
SELECT card_id FROM card_column_history
WHERE exited_at IS NULL AND entered_at < ?
)
`, now, now, cutoff)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}
// ListArchivedCards returns cards in the archive, newest first.
func (db *DB) ListArchivedCards() ([]Card, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.seq_num, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.locked, c.assignee_id, c.completed_at, c.deleted_at, c.archived_at, c.tags, c.stickers, c.deadline, c.created_at, c.updated_at
FROM cards c
WHERE c.archived_at IS NOT NULL AND c.deleted_at IS NULL
ORDER BY c.archived_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Card{}
for rows.Next() {
var c Card
var assignee, completed, deleted, archived sql.NullString
var tagsJSON, stickersJSON string
var deadline sql.NullString
var locked int
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &locked, &assignee, &completed, &deleted, &archived, &tagsJSON, &stickersJSON, &deadline, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
c.Stickers = parseStickers(stickersJSON)
if deadline.Valid && deadline.String != "" {
s := deadline.String
c.Deadline = &s
}
c.Locked = locked != 0
if assignee.Valid && assignee.String != "" {
s := assignee.String
c.AssigneeID = &s
}
if completed.Valid && completed.String != "" {
s := completed.String
c.CompletedAt = &s
}
if archived.Valid && archived.String != "" {
s := archived.String
c.ArchivedAt = &s
}
c.Tags = parseTags(tagsJSON)
out = append(out, c)
}
return out, rows.Err()
}
// MoveCard updates the card's column and/or position. If the column changes,
// the open history entry is closed and a new one is opened.
// orderedIDs is the new order of cards in the destination column (including this card).
@@ -1031,3 +1132,171 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
CurrentlyLock: currently,
}, nil
}
type CardMessage struct {
ID string `json:"id"`
CardID string `json:"card_id"`
AuthorID *string `json:"author_id"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
}
func (db *DB) ListCardMessages(cardID string) ([]CardMessage, error) {
rows, err := db.conn.Query(
`SELECT id, card_id, author_id, body, created_at FROM card_messages WHERE card_id=? ORDER BY created_at`,
cardID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CardMessage{}
for rows.Next() {
var m CardMessage
var author sql.NullString
if err := rows.Scan(&m.ID, &m.CardID, &author, &m.Body, &m.CreatedAt); err != nil {
return nil, err
}
if author.Valid && author.String != "" {
s := author.String
m.AuthorID = &s
}
out = append(out, m)
}
return out, rows.Err()
}
func (db *DB) CreateCardMessage(cardID, authorID, body string) (*CardMessage, error) {
body = strings.TrimSpace(body)
if body == "" {
return nil, fmt.Errorf("body required")
}
if authorID == "" {
return nil, fmt.Errorf("author required")
}
var exists int
if err := db.conn.QueryRow(`SELECT 1 FROM cards WHERE id=?`, cardID).Scan(&exists); err != nil {
return nil, fmt.Errorf("card not found: %w", err)
}
s := authorID
m := &CardMessage{ID: newID(), CardID: cardID, AuthorID: &s, Body: body, CreatedAt: nowRFC3339()}
if _, err := db.conn.Exec(
`INSERT INTO card_messages (id, card_id, author_id, body, created_at) VALUES (?, ?, ?, ?, ?)`,
m.ID, m.CardID, authorID, m.Body, m.CreatedAt,
); err != nil {
return nil, err
}
return m, nil
}
func (db *DB) DeleteCardMessage(id, requesterID string) error {
if requesterID == "" {
return fmt.Errorf("session required")
}
res, err := db.conn.Exec(`DELETE FROM card_messages WHERE id=? AND author_id=?`, id, requesterID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("not found or not author")
}
return nil
}
// DuplicateCard clones a card into the same column at the end of the list.
// Copies title, description, color, requester, assignee, tags, deadline, stickers.
// Does NOT copy card_column_history, card_lock_history, card_events, card_messages.
// Title gets " (copia)" suffix.
func (db *DB) DuplicateCard(srcID, actorID string) (*Card, error) {
tx, err := db.conn.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var src Card
var assignee sql.NullString
var deadline sql.NullString
var tagsJSON, stickersJSON string
if err := tx.QueryRow(
`SELECT requester, title, description, color, column_id, assignee_id, tags, stickers, deadline
FROM cards WHERE id=? AND deleted_at IS NULL`, srcID,
).Scan(&src.Requester, &src.Title, &src.Description, &src.Color, &src.ColumnID, &assignee, &tagsJSON, &stickersJSON, &deadline); err != nil {
return nil, fmt.Errorf("card not found: %w", err)
}
if assignee.Valid && assignee.String != "" {
s := assignee.String
src.AssigneeID = &s
}
if deadline.Valid && deadline.String != "" {
s := deadline.String
src.Deadline = &s
}
src.Tags = parseTags(tagsJSON)
src.Stickers = parseStickers(stickersJSON)
var maxPos sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, src.ColumnID).Scan(&maxPos); err != nil {
return nil, err
}
pos := 0
if maxPos.Valid {
pos = int(maxPos.Int64) + 1
}
var maxSeq sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(seq_num) FROM cards`).Scan(&maxSeq); err != nil {
return nil, err
}
seqNum := 1
if maxSeq.Valid {
seqNum = int(maxSeq.Int64) + 1
}
now := nowRFC3339()
newTitle := src.Title + " (copia)"
c := Card{
ID: newID(), SeqNum: seqNum, Requester: src.Requester, Title: newTitle,
Description: src.Description, Color: src.Color, ColumnID: src.ColumnID, Position: pos,
AssigneeID: src.AssigneeID, Tags: src.Tags, Stickers: src.Stickers, Deadline: src.Deadline,
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
}
var assigneeVal any
if c.AssigneeID != nil && *c.AssigneeID != "" {
assigneeVal = *c.AssigneeID
}
var deadlineVal any
if c.Deadline != nil && *c.Deadline != "" {
deadlineVal = *c.Deadline
}
if _, err := tx.Exec(
`INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, assignee_id, tags, stickers, deadline, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position,
assigneeVal, encodeTags(c.Tags), encodeStickers(c.Stickers), deadlineVal, c.CreatedAt, c.UpdatedAt,
); err != nil {
return nil, err
}
if _, err := tx.Exec(
`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
newID(), c.ID, c.ColumnID, now, nullableActor(actorID),
); err != nil {
return nil, err
}
var destDone int
if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, c.ColumnID).Scan(&destDone); err != nil {
return nil, err
}
if destDone == 1 {
if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil {
return nil, err
}
c.CompletedAt = &now
}
if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": newTitle, "column_id": c.ColumnID, "duplicated_from": srcID}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &c, nil
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title>
<script type="module" crossorigin src="/assets/index-BKxzRoLi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
<script type="module" crossorigin src="/assets/index-Be_Ib5cu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head>
<body>
<div id="root"></div>
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"encoding/json"
"sync"
"sync/atomic"
"time"
)
// EventHub is an in-process pub/sub used to push board mutations and
// notifications to connected clients (SSE for board-wide events, WS for
// per-card chat). Drop policy on slow consumers: best-effort send; if a
// subscriber's buffered channel is full the event is dropped and the
// hub increments dropCount. Clients are expected to reconcile state via
// a full reload when reconnecting.
type EventHub struct {
mu sync.RWMutex
userSubs map[string]map[chan Event]struct{}
cardSubs map[string]map[chan Event]struct{}
dropCount uint64
}
// Event is the envelope broadcast to subscribers.
//
// Type — discriminator (e.g. "card.updated", "message.created").
// CardID — set when payload pertains to a specific card.
// UserID — set for per-user private events (e.g. notifications). Empty
// means broadcast to every user subscriber.
// Payload — arbitrary JSON describing the change.
// TS — RFC3339 timestamp.
type Event struct {
Type string `json:"type"`
CardID string `json:"card_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
TS string `json:"ts"`
}
const eventBufSize = 64
func NewEventHub() *EventHub {
return &EventHub{
userSubs: map[string]map[chan Event]struct{}{},
cardSubs: map[string]map[chan Event]struct{}{},
}
}
// SubscribeUser returns a channel that receives every public event plus
// private events targeted at userID. Caller MUST eventually call
// UnsubscribeUser to release resources.
func (h *EventHub) SubscribeUser(userID string) chan Event {
ch := make(chan Event, eventBufSize)
h.mu.Lock()
set, ok := h.userSubs[userID]
if !ok {
set = map[chan Event]struct{}{}
h.userSubs[userID] = set
}
set[ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *EventHub) UnsubscribeUser(userID string, ch chan Event) {
h.mu.Lock()
if set, ok := h.userSubs[userID]; ok {
delete(set, ch)
if len(set) == 0 {
delete(h.userSubs, userID)
}
}
h.mu.Unlock()
close(ch)
}
// SubscribeCard returns a channel that receives events scoped to cardID
// (chat messages + typing indicators).
func (h *EventHub) SubscribeCard(cardID string) chan Event {
ch := make(chan Event, eventBufSize)
h.mu.Lock()
set, ok := h.cardSubs[cardID]
if !ok {
set = map[chan Event]struct{}{}
h.cardSubs[cardID] = set
}
set[ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *EventHub) UnsubscribeCard(cardID string, ch chan Event) {
h.mu.Lock()
if set, ok := h.cardSubs[cardID]; ok {
delete(set, ch)
if len(set) == 0 {
delete(h.cardSubs, cardID)
}
}
h.mu.Unlock()
close(ch)
}
// Publish delivers ev to every matching subscriber. If ev.UserID is set
// it is delivered ONLY to that user's subscribers; otherwise it fans out
// to all user subscribers. Card subscribers ALWAYS receive events that
// match ev.CardID. Best-effort: full channels are skipped.
func (h *EventHub) Publish(ev Event) {
if ev.TS == "" {
ev.TS = time.Now().UTC().Format(time.RFC3339)
}
h.mu.RLock()
defer h.mu.RUnlock()
deliver := func(ch chan Event) {
select {
case ch <- ev:
default:
atomic.AddUint64(&h.dropCount, 1)
}
}
if ev.UserID != "" {
if set, ok := h.userSubs[ev.UserID]; ok {
for ch := range set {
deliver(ch)
}
}
} else {
for _, set := range h.userSubs {
for ch := range set {
deliver(ch)
}
}
}
if ev.CardID != "" {
if set, ok := h.cardSubs[ev.CardID]; ok {
for ch := range set {
deliver(ch)
}
}
}
}
func (h *EventHub) DropCount() uint64 {
return atomic.LoadUint64(&h.dropCount)
}
// PublishJSON marshals payload and publishes a single Event.
func (h *EventHub) PublishJSON(typ, cardID, userID string, payload interface{}) {
var raw json.RawMessage
if payload != nil {
b, err := json.Marshal(payload)
if err == nil {
raw = b
}
}
h.Publish(Event{Type: typ, CardID: cardID, UserID: userID, Payload: raw})
}
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestEventHub_BroadcastToAllUsers(t *testing.T) {
hub := NewEventHub()
chA := hub.SubscribeUser("alice")
chB := hub.SubscribeUser("bob")
defer hub.UnsubscribeUser("alice", chA)
defer hub.UnsubscribeUser("bob", chB)
hub.PublishJSON("card.updated", "c1", "", map[string]string{"id": "c1"})
for _, ch := range []chan Event{chA, chB} {
select {
case ev := <-ch:
if ev.Type != "card.updated" {
t.Fatalf("type = %q, want card.updated", ev.Type)
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for event")
}
}
}
func TestEventHub_PrivateUserEvent(t *testing.T) {
hub := NewEventHub()
chA := hub.SubscribeUser("alice")
chB := hub.SubscribeUser("bob")
defer hub.UnsubscribeUser("alice", chA)
defer hub.UnsubscribeUser("bob", chB)
hub.PublishJSON("notification.created", "", "alice", map[string]string{"foo": "bar"})
select {
case ev := <-chA:
if ev.UserID != "alice" {
t.Fatalf("user_id = %q, want alice", ev.UserID)
}
case <-time.After(time.Second):
t.Fatal("alice did not get private event")
}
select {
case ev := <-chB:
t.Fatalf("bob received private event for alice: %+v", ev)
case <-time.After(100 * time.Millisecond):
// expected
}
}
func TestEventHub_CardSubscription(t *testing.T) {
hub := NewEventHub()
ch := hub.SubscribeCard("card-1")
defer hub.UnsubscribeCard("card-1", ch)
hub.PublishJSON("message.created", "card-1", "", map[string]string{"id": "m1"})
hub.PublishJSON("message.created", "card-2", "", map[string]string{"id": "m2"})
select {
case ev := <-ch:
if ev.CardID != "card-1" {
t.Fatalf("card_id = %q, want card-1", ev.CardID)
}
case <-time.After(time.Second):
t.Fatal("timeout")
}
select {
case ev := <-ch:
t.Fatalf("received unexpected event for other card: %+v", ev)
case <-time.After(100 * time.Millisecond):
}
}
func TestEventHub_DropPolicyOnSlowConsumer(t *testing.T) {
hub := NewEventHub()
ch := hub.SubscribeUser("slow")
defer hub.UnsubscribeUser("slow", ch)
// Fill the buffer + N extra to force drops.
const extra = 50
for i := 0; i < eventBufSize+extra; i++ {
hub.PublishJSON("noise", "", "slow", nil)
}
if got := hub.DropCount(); got < extra {
t.Fatalf("DropCount = %d, want >= %d", got, extra)
}
}
func TestEventHub_UnsubscribeRemoves(t *testing.T) {
hub := NewEventHub()
ch := hub.SubscribeUser("alice")
hub.UnsubscribeUser("alice", ch)
// channel must be closed
select {
case _, ok := <-ch:
if ok {
t.Fatal("expected closed channel")
}
default:
// channel could be drained-and-closed
}
// Publish should not panic and should not deliver anywhere.
hub.PublishJSON("noise", "", "alice", nil)
}
func TestEventHub_ConcurrentPublishers(t *testing.T) {
hub := NewEventHub()
ch := hub.SubscribeUser("u")
defer hub.UnsubscribeUser("u", ch)
var received atomic.Uint64
done := make(chan struct{})
go func() {
for range ch {
received.Add(1)
}
close(done)
}()
var wg sync.WaitGroup
const writers = 10
const each = 100
for i := 0; i < writers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < each; j++ {
hub.PublishJSON("ping", "", "u", nil)
}
}()
}
wg.Wait()
// Give the consumer time to drain.
time.Sleep(200 * time.Millisecond)
got := received.Load()
dropped := hub.DropCount()
if got+dropped < writers*each {
t.Fatalf("received=%d drop=%d want sum >= %d", got, dropped, writers*each)
}
}
+346
View File
@@ -0,0 +1,346 @@
package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"fn-registry/functions/infra"
)
// Issue 0128: adjuntos de archivos por card.
const (
maxUploadBytes = 10 << 20 // 10 MiB
uploadsSubdir = "uploads"
)
type CardFile struct {
ID string `json:"id"`
CardID string `json:"card_id"`
UploaderID string `json:"uploader_id"`
Filename string `json:"filename"`
MIME string `json:"mime"`
Size int64 `json:"size"`
Source string `json:"source"`
URL string `json:"url"`
CreatedAt string `json:"created_at"`
}
func (db *DB) CreateCardFile(cardID, uploaderID, filename, mimeType, storedPath, source string, size int64) (*CardFile, error) {
id := newID()
now := nowRFC3339()
if source == "" {
source = "upload"
}
_, err := db.conn.Exec(
`INSERT INTO card_files
(id, card_id, uploader_id, filename, mime, size, stored_path, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, cardID, uploaderID, filename, mimeType, size, storedPath, source, now,
)
if err != nil {
return nil, err
}
return &CardFile{
ID: id,
CardID: cardID,
UploaderID: uploaderID,
Filename: filename,
MIME: mimeType,
Size: size,
Source: source,
URL: "/api/files/" + id,
CreatedAt: now,
}, nil
}
func (db *DB) ListCardFiles(cardID string) ([]CardFile, error) {
rows, err := db.conn.Query(
`SELECT id, card_id, uploader_id, filename, mime, size, source, created_at
FROM card_files
WHERE card_id = ? AND deleted_at IS NULL
ORDER BY created_at ASC`,
cardID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CardFile{}
for rows.Next() {
var f CardFile
if err := rows.Scan(&f.ID, &f.CardID, &f.UploaderID, &f.Filename, &f.MIME, &f.Size, &f.Source, &f.CreatedAt); err != nil {
return nil, err
}
f.URL = "/api/files/" + f.ID
out = append(out, f)
}
return out, rows.Err()
}
type storedCardFile struct {
ID string
CardID string
Filename string
MIME string
Size int64
StoredPath string
}
func (db *DB) GetCardFile(id string) (*storedCardFile, error) {
var f storedCardFile
err := db.conn.QueryRow(
`SELECT id, card_id, filename, mime, size, stored_path
FROM card_files
WHERE id = ? AND deleted_at IS NULL`,
id,
).Scan(&f.ID, &f.CardID, &f.Filename, &f.MIME, &f.Size, &f.StoredPath)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &f, nil
}
func (db *DB) SoftDeleteCardFile(id string) (int64, error) {
res, err := db.conn.Exec(
`UPDATE card_files SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
nowRFC3339(), id,
)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func uploadsDir(workdir string) string {
return filepath.Join(workdir, uploadsSubdir)
}
func safeFilename(name string) string {
name = filepath.Base(name)
name = strings.ReplaceAll(name, string(os.PathSeparator), "_")
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "file"
}
return name
}
func randomFilePrefix() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// POST /api/cards/{id}/files (multipart, field "file", optional "source")
func handleUploadCardFile(db *DB, workdir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cardID := r.PathValue("id")
if cardID == "" {
badRequest(w, "card id required")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1<<20)
if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
badRequest(w, "multipart parse: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
badRequest(w, "missing 'file' field: "+err.Error())
return
}
defer file.Close()
if header.Size > maxUploadBytes {
infra.HTTPErrorResponse(w, infra.HTTPError{
Status: http.StatusRequestEntityTooLarge,
Code: "file_too_large",
Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes),
})
return
}
source := r.FormValue("source")
switch source {
case "", "upload":
source = "upload"
case "description", "chat":
// keep
default:
source = "upload"
}
dir := filepath.Join(uploadsDir(workdir), cardID)
if err := os.MkdirAll(dir, 0o755); err != nil {
serverError(w, fmt.Errorf("mkdir uploads: %w", err))
return
}
fname := safeFilename(header.Filename)
storedPath := filepath.Join(dir, randomFilePrefix()+"__"+fname)
out, err := os.Create(storedPath)
if err != nil {
serverError(w, fmt.Errorf("create file: %w", err))
return
}
written, copyErr := io.Copy(out, file)
closeErr := out.Close()
if copyErr != nil {
os.Remove(storedPath)
serverError(w, fmt.Errorf("write file: %w", copyErr))
return
}
if closeErr != nil {
os.Remove(storedPath)
serverError(w, fmt.Errorf("close file: %w", closeErr))
return
}
if written > maxUploadBytes {
os.Remove(storedPath)
infra.HTTPErrorResponse(w, infra.HTTPError{
Status: http.StatusRequestEntityTooLarge,
Code: "file_too_large",
Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes),
})
return
}
mimeType := header.Header.Get("Content-Type")
if mimeType == "" {
mimeType = mime.TypeByExtension(filepath.Ext(fname))
}
if mimeType == "" {
mimeType = "application/octet-stream"
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
cf, err := db.CreateCardFile(cardID, actor, fname, mimeType, storedPath, source, written)
if err != nil {
os.Remove(storedPath)
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, cf)
}
}
// GET /api/cards/{id}/files
func handleListCardFiles(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cardID := r.PathValue("id")
if cardID == "" {
badRequest(w, "card id required")
return
}
files, err := db.ListCardFiles(cardID)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, files)
}
}
// GET /api/files/{id}
func handleServeFile(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
badRequest(w, "file id required")
return
}
f, err := db.GetCardFile(id)
if err != nil {
serverError(w, err)
return
}
if f == nil {
notFound(w, "file not found")
return
}
fh, err := os.Open(f.StoredPath)
if err != nil {
notFound(w, "file missing on disk")
return
}
defer fh.Close()
if f.MIME != "" {
w.Header().Set("Content-Type", f.MIME)
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
disposition := "inline"
if !isInlineMIME(f.MIME) {
disposition = "attachment"
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, sanitizeHeaderFilename(f.Filename)))
w.Header().Set("Cache-Control", "private, max-age=3600")
_, _ = io.Copy(w, fh)
}
}
// DELETE /api/files/{id}
func handleDeleteCardFile(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
badRequest(w, "file id required")
return
}
n, err := db.SoftDeleteCardFile(id)
if err != nil {
serverError(w, err)
return
}
if n == 0 {
notFound(w, "file not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func isInlineMIME(m string) bool {
if m == "" {
return false
}
m = strings.ToLower(m)
switch {
case strings.HasPrefix(m, "image/"):
return true
case m == "application/pdf":
return true
case strings.HasPrefix(m, "text/"):
return true
}
return false
}
func sanitizeHeaderFilename(name string) string {
name = strings.ReplaceAll(name, `"`, "")
name = strings.ReplaceAll(name, "\n", "")
name = strings.ReplaceAll(name, "\r", "")
return name
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"encoding/json"
"net/http"
"os"
"fn-registry/functions/infra"
)
type FeatureFlag struct {
Enabled bool `json:"enabled"`
Issue string `json:"issue,omitempty"`
Description string `json:"description"`
Added string `json:"added,omitempty"`
EnabledAt string `json:"enabled_at,omitempty"`
}
type FeatureFlags struct {
Flags map[string]FeatureFlag `json:"flags"`
}
func (f FeatureFlags) Enabled(name string) bool {
flag, ok := f.Flags[name]
return ok && flag.Enabled
}
func loadFeatureFlags(path string) (FeatureFlags, error) {
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return FeatureFlags{Flags: map[string]FeatureFlag{}}, nil
}
return FeatureFlags{}, err
}
var f FeatureFlags
if err := json.Unmarshal(b, &f); err != nil {
return FeatureFlags{}, err
}
if f.Flags == nil {
f.Flags = map[string]FeatureFlag{}
}
return f, nil
}
// GET /api/flags → { "<name>": true/false, ... }
func handleListFlags(flags *FeatureFlags) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := make(map[string]bool, len(flags.Flags))
for name, fl := range flags.Flags {
out[name] = fl.Enabled
}
infra.HTTPJSONResponse(w, http.StatusOK, out)
}
}
+33 -11
View File
@@ -2,20 +2,31 @@ module kanban
go 1.25.0
require fn-registry v0.0.0-00010101000000-000000000000
require (
fn-registry v0.0.0-00010101000000-000000000000
github.com/mattn/go-sqlite3 v1.14.44
golang.org/x/crypto v0.51.0
nhooyr.io/websocket v1.8.17
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apache/arrow-go/v18 v18.1.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/flatbuffers v25.1.24+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
@@ -23,27 +34,38 @@ require (
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/marcboeker/go-duckdb v1.8.5 // indirect
github.com/mattn/go-sqlite3 v1.14.37 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/paulmach/orb v0.12.0 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/rs/zerolog v1.35.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.mau.fi/util v0.9.9 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
nhooyr.io/websocket v1.8.17 // indirect
maunium.net/go/mautrix v0.28.0 // indirect
)
replace fn-registry => ../../..
+65 -18
View File
@@ -1,18 +1,28 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
@@ -21,6 +31,8 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -34,6 +46,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -60,8 +74,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
@@ -70,6 +90,8 @@ github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJ
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -77,17 +99,33 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
@@ -96,10 +134,16 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE=
go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
@@ -111,21 +155,21 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -138,23 +182,24 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -172,5 +217,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
+405 -34
View File
@@ -1,14 +1,46 @@
package main
import (
"log"
"net/http"
"strings"
"sync/atomic"
"time"
"fn-registry/functions/infra"
)
const maxBodyBytes = 1 << 20 // 1 MiB
// Auto-archive: cards en columnas Done con >30 dias se mueven al cajon.
// Issue 0092. Lo dispara handleGetBoard de forma "lazy" pero solo cada
// archiveSweepEvery minutos para no martillear el UPDATE.
const (
archiveAfter = 30 * 24 * time.Hour
archiveSweepEvery = 30 * time.Minute
)
var lastArchiveSweepNs atomic.Int64
func maybeAutoArchive(db *DB) {
now := time.Now().UnixNano()
last := lastArchiveSweepNs.Load()
if last != 0 && time.Duration(now-last) < archiveSweepEvery {
return
}
if !lastArchiveSweepNs.CompareAndSwap(last, now) {
return
}
n, err := db.AutoArchiveDoneOlderThan(archiveAfter)
if err != nil {
log.Printf("auto-archive failed: %v", err)
return
}
if n > 0 {
log.Printf("auto-archive moved %d done card(s) older than %s", n, archiveAfter)
}
}
func badRequest(w http.ResponseWriter, msg string) {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: msg})
}
@@ -24,6 +56,7 @@ func serverError(w http.ResponseWriter, err error) {
// GET /api/board → { columns: [...], cards: [...] }
func handleGetBoard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
maybeAutoArchive(db)
cols, err := db.ListColumns()
if err != nil {
serverError(w, err)
@@ -41,8 +74,21 @@ func handleGetBoard(db *DB) http.HandlerFunc {
}
}
// publishInvalidated emits a board.invalidated event so connected clients
// refetch /api/board. Best-effort: dropped events recover on next mutation
// or via the periodic safety reload kept in the SPA.
func publishInvalidated(hub *EventHub, cardID, columnID string) {
if hub == nil {
return
}
hub.PublishJSON("board.invalidated", cardID, "", map[string]string{
"card_id": cardID,
"column_id": columnID,
})
}
// POST /api/columns { name }
func handleCreateColumn(db *DB) http.HandlerFunc {
func handleCreateColumn(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct{ Name string `json:"name"` }
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
@@ -58,48 +104,52 @@ func handleCreateColumn(db *DB) http.HandlerFunc {
serverError(w, err)
return
}
publishInvalidated(hub, "", c.ID)
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
// PATCH /api/columns/{id} { name?, position?, location?, width? }
func handleUpdateColumn(db *DB) http.HandlerFunc {
func handleUpdateColumn(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Name *string `json:"name"`
Position *int `json:"position"`
Location *string `json:"location"`
Width *int `json:"width"`
WIPLimit *int `json:"wip_limit"`
IsDone *bool `json:"is_done"`
Name *string `json:"name"`
Position *int `json:"position"`
Location *string `json:"location"`
Width *int `json:"width"`
WIPLimit *int `json:"wip_limit"`
IsDone *bool `json:"is_done"`
MaxTimeMinutes *int `json:"max_time_minutes"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone}); err != nil {
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone, MaxTimeMinutes: body.MaxTimeMinutes}); err != nil {
serverError(w, err)
return
}
publishInvalidated(hub, "", id)
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/columns/{id}
func handleDeleteColumn(db *DB) http.HandlerFunc {
func handleDeleteColumn(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.DeleteColumn(id); err != nil {
serverError(w, err)
return
}
publishInvalidated(hub, "", id)
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/columns/reorder { ids: [...] }
func handleReorderColumns(db *DB) http.HandlerFunc {
func handleReorderColumns(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct{ IDs []string `json:"ids"` }
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
@@ -110,12 +160,13 @@ func handleReorderColumns(db *DB) http.HandlerFunc {
serverError(w, err)
return
}
publishInvalidated(hub, "", "")
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards { column_id, requester?, title, description? }
func handleCreateCard(db *DB) http.HandlerFunc {
func handleCreateCard(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
ColumnID string `json:"column_id"`
@@ -152,12 +203,20 @@ func handleCreateCard(db *DB) http.HandlerFunc {
serverError(w, err)
return
}
// card.created drives outbound modules (Jira) to create the issue.
// Emitted after assignee/tags are applied so the synced issue carries
// them. board.invalidated stays for the SPA's refetch path.
hub.PublishJSON("card.created", c.ID, "", map[string]string{
"card_id": c.ID,
"column_id": body.ColumnID,
})
publishInvalidated(hub, c.ID, body.ColumnID)
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
// PATCH /api/cards/{id} { requester?, title?, description?, color? }
func handleUpdateCard(db *DB) http.HandlerFunc {
func handleUpdateCard(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var raw map[string]any
@@ -215,12 +274,13 @@ func handleUpdateCard(db *DB) http.HandlerFunc {
serverError(w, err)
return
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
// PUT /api/cards/{id}/stickers { stickers: [{emoji,x,y}, ...] }
func handleUpdateCardStickers(db *DB) http.HandlerFunc {
func handleUpdateCardStickers(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
@@ -234,12 +294,13 @@ func handleUpdateCardStickers(db *DB) http.HandlerFunc {
serverError(w, err)
return
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/cards/{id}
func handleDeleteCard(db *DB) http.HandlerFunc {
func handleDeleteCard(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
@@ -247,12 +308,13 @@ func handleDeleteCard(db *DB) http.HandlerFunc {
serverError(w, err)
return
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards/{id}/move { column_id, ordered_ids }
func handleMoveCard(db *DB) http.HandlerFunc {
func handleMoveCard(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
@@ -267,6 +329,10 @@ func handleMoveCard(db *DB) http.HandlerFunc {
badRequest(w, "column_id required")
return
}
// Read the previous column BEFORE mutating so we can decide whether
// this is an actual column move (vs a same-column reorder). Outbound
// modules (Jira) only care about the former.
prevColumnID, _ := db.lookupCardColumnID(id)
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, actor); err != nil {
if strings.Contains(err.Error(), "not found") {
@@ -276,10 +342,115 @@ func handleMoveCard(db *DB) http.HandlerFunc {
serverError(w, err)
return
}
// Distinct event when the card crossed columns so the Jira module
// runs transition() instead of plain update(). Reorder-only goes
// straight to board.invalidated (frontend refetch) without a Jira
// roundtrip.
if prevColumnID != "" && prevColumnID != body.ColumnID {
hub.PublishJSON("card.moved", id, "", map[string]string{
"card_id": id,
"from_column_id": prevColumnID,
"to_column_id": body.ColumnID,
})
}
publishInvalidated(hub, id, body.ColumnID)
w.WriteHeader(http.StatusNoContent)
}
}
// GET /api/cards/{id}/messages → [CardMessage, ...]
func handleListCardMessages(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
msgs, err := db.ListCardMessages(id)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, msgs)
}
}
// POST /api/cards/{id}/messages { body }
//
// Parses @mentions, fans out notifications and publishes message.created via
// the hub so SSE/WS subscribers see the message immediately.
func handleCreateCardMessage(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Body string `json:"body"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if strings.TrimSpace(body.Body) == "" {
badRequest(w, "body required")
return
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if actor == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
m, _, _, err := db.CreateCardMessageAndNotify(id, actor, body.Body, hub)
if err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, err.Error())
return
}
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, m)
}
}
// DELETE /api/cards/{cid}/messages/{mid}
func handleDeleteCardMessage(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cid := r.PathValue("id")
mid := r.PathValue("mid")
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if actor == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
if err := db.DeleteCardMessage(mid, actor); err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, err.Error())
return
}
serverError(w, err)
return
}
if hub != nil {
hub.PublishJSON("message.deleted", cid, "", map[string]string{"id": mid})
}
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards/{id}/duplicate
func handleDuplicateCard(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
c, err := db.DuplicateCard(id, actor)
if err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, "card not found")
return
}
serverError(w, err)
return
}
publishInvalidated(hub, c.ID, c.ColumnID)
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
// GET /api/cards/{id}/history → [HistoryEntry, ...]
func handleCardHistory(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -306,7 +477,7 @@ func handleListTrash(db *DB) http.HandlerFunc {
}
// POST /api/cards/{id}/restore
func handleRestoreCard(db *DB) http.HandlerFunc {
func handleRestoreCard(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
@@ -314,15 +485,107 @@ func handleRestoreCard(db *DB) http.HandlerFunc {
serverError(w, err)
return
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/cards/{id}/purge
func handlePurgeCard(db *DB) http.HandlerFunc {
// GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid
func handleDailyReport(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.PurgeCard(id); err != nil {
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().UTC().Format("2006-01-02")
}
tz := r.URL.Query().Get("tz")
if tz == "" {
tz = "Europe/Madrid"
}
rep, err := db.DailyReportFor(date, tz)
if err != nil {
badRequest(w, err.Error())
return
}
infra.HTTPJSONResponse(w, http.StatusOK, rep)
}
}
// GET /api/reports/daily/summary?date=YYYY-MM-DD
func handleGetDailySummary(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().UTC().Format("2006-01-02")
}
s, err := db.GetDailySummary(date)
if err != nil {
serverError(w, err)
return
}
if s == nil {
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{"date": date, "summary": "", "exists": false})
return
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
"date": s.Date, "summary": s.Summary, "prompt": s.Prompt,
"model": s.Model, "generated_at": s.GeneratedAt, "generated_by": s.GeneratedBy,
"exists": true,
})
}
}
// POST /api/reports/daily/summary?date=YYYY-MM-DD&tz=Europe/Madrid
// Regenera el resumen del dia y lo persiste.
func handleGenerateDailySummary(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().UTC().Format("2006-01-02")
}
tz := r.URL.Query().Get("tz")
if tz == "" {
tz = "Europe/Madrid"
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
rec, err := db.GenerateDailySummary(r.Context(), date, tz, actor)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, rec)
}
}
// GET /api/settings/{key}
func handleGetSetting(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
v, err := db.GetSetting(key)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{"key": key, "value": v})
}
}
// PUT /api/settings/{key} body: {"value": "..."}
func handlePutSetting(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
var body struct {
Value string `json:"value"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
var actorPtr *string
if actor != "" {
actorPtr = &actor
}
if err := db.SetSetting(key, body.Value, actorPtr); err != nil {
serverError(w, err)
return
}
@@ -330,32 +593,140 @@ func handlePurgeCard(db *DB) http.HandlerFunc {
}
}
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route {
// GET /api/archive
func handleListArchive(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cards, err := db.ListArchivedCards()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, cards)
}
}
// POST /api/cards/{id}/archive
func handleArchiveCard(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.ArchiveCard(id); err != nil {
serverError(w, err)
return
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards/{id}/unarchive
func handleUnarchiveCard(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.UnarchiveCard(id); err != nil {
serverError(w, err)
return
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/cards/{id}/purge
func handlePurgeCard(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.PurgeCard(id); err != nil {
serverError(w, err)
return
}
publishInvalidated(hub, id, "")
w.WriteHeader(http.StatusNoContent)
}
}
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string, flags *FeatureFlags, hub *EventHub, dispatcher *Dispatcher) []infra.Route {
return []infra.Route{
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db)},
{Method: "GET", Path: "/api/flags", Handler: handleListFlags(flags)},
{Method: "GET", Path: "/api/version", Handler: handleVersion()},
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db, flags)},
{Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)},
{Method: "POST", Path: "/api/auth/logout", Handler: handleLogout(db)},
{Method: "GET", Path: "/api/me", Handler: handleMe(db)},
{Method: "PATCH", Path: "/api/me", Handler: handlePatchMe(db)},
{Method: "GET", Path: "/api/users", Handler: handleListUsers(db)},
{Method: "GET", Path: "/api/board", Handler: handleGetBoard(db)},
{Method: "POST", Path: "/api/columns", Handler: handleCreateColumn(db)},
{Method: "POST", Path: "/api/columns/reorder", Handler: handleReorderColumns(db)},
{Method: "PATCH", Path: "/api/columns/{id}", Handler: handleUpdateColumn(db)},
{Method: "DELETE", Path: "/api/columns/{id}", Handler: handleDeleteColumn(db)},
{Method: "POST", Path: "/api/cards", Handler: handleCreateCard(db)},
{Method: "PATCH", Path: "/api/cards/{id}", Handler: handleUpdateCard(db)},
{Method: "PUT", Path: "/api/cards/{id}/stickers", Handler: handleUpdateCardStickers(db)},
{Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db)},
{Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db)},
{Method: "POST", Path: "/api/columns", Handler: handleCreateColumn(db, hub)},
{Method: "POST", Path: "/api/columns/reorder", Handler: handleReorderColumns(db, hub)},
{Method: "PATCH", Path: "/api/columns/{id}", Handler: handleUpdateColumn(db, hub)},
{Method: "DELETE", Path: "/api/columns/{id}", Handler: handleDeleteColumn(db, hub)},
{Method: "POST", Path: "/api/cards", Handler: handleCreateCard(db, hub)},
{Method: "PATCH", Path: "/api/cards/{id}", Handler: handleUpdateCard(db, hub)},
{Method: "PUT", Path: "/api/cards/{id}/stickers", Handler: handleUpdateCardStickers(db, hub)},
{Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db, hub)},
{Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db, hub)},
{Method: "POST", Path: "/api/cards/{id}/duplicate", Handler: handleDuplicateCard(db, hub)},
{Method: "GET", Path: "/api/cards/{id}/messages", Handler: handleListCardMessages(db)},
{Method: "POST", Path: "/api/cards/{id}/messages", Handler: handleCreateCardMessage(db, hub)},
{Method: "DELETE", Path: "/api/cards/{id}/messages/{mid}", Handler: handleDeleteCardMessage(db, hub)},
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)},
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db, hub)},
{Method: "GET", Path: "/api/reports/daily", Handler: handleDailyReport(db)},
{Method: "GET", Path: "/api/reports/daily/summary", Handler: handleGetDailySummary(db)},
{Method: "POST", Path: "/api/reports/daily/summary", Handler: handleGenerateDailySummary(db)},
{Method: "GET", Path: "/api/settings/{key}", Handler: handleGetSetting(db)},
{Method: "PUT", Path: "/api/settings/{key}", Handler: handlePutSetting(db)},
{Method: "GET", Path: "/api/archive", Handler: handleListArchive(db)},
{Method: "POST", Path: "/api/cards/{id}/archive", Handler: handleArchiveCard(db, hub)},
{Method: "POST", Path: "/api/cards/{id}/unarchive", Handler: handleUnarchiveCard(db, hub)},
{Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db, hub)},
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
{Method: "GET", Path: "/api/chat/ws", Handler: handleChatWS(db, chatWorkdir, logger, internalToken)},
{Method: "POST", Path: "/api/tool/{name}", Handler: handleInternalTool(db, internalToken, logger)},
{Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)},
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
// Issue 0128: adjuntos de archivos.
{Method: "POST", Path: "/api/cards/{id}/files", Handler: handleUploadCardFile(db, chatWorkdir)},
{Method: "GET", Path: "/api/cards/{id}/files", Handler: handleListCardFiles(db)},
{Method: "GET", Path: "/api/files/{id}", Handler: handleServeFile(db)},
{Method: "DELETE", Path: "/api/files/{id}", Handler: handleDeleteCardFile(db)},
// Notifications + realtime (issue notifications-realtime).
{Method: "GET", Path: "/api/events", Handler: handleEventStream(hub)},
{Method: "GET", Path: "/api/cards/{id}/chat/ws", Handler: handleCardChatWS(db, hub)},
{Method: "GET", Path: "/api/notifications", Handler: handleListNotifications(db)},
{Method: "GET", Path: "/api/notifications/unread-count", Handler: handleUnreadCount(db)},
{Method: "POST", Path: "/api/notifications/{id}/read", Handler: handleMarkNotificationRead(db, hub)},
{Method: "POST", Path: "/api/notifications/read-all", Handler: handleMarkAllNotificationsRead(db, hub)},
// MCP per-user tokens.
{Method: "POST", Path: "/api/mcp-tokens", Handler: handleCreateMCPToken(db)},
{Method: "GET", Path: "/api/mcp-tokens", Handler: handleListMCPTokens(db)},
{Method: "DELETE", Path: "/api/mcp-tokens/{id}", Handler: handleRevokeMCPToken(db)},
// Modules: external integrations (Jira, ...).
{Method: "GET", Path: "/api/modules", Handler: handleListModules(db)},
{Method: "POST", Path: "/api/modules", Handler: handleCreateModule(db)},
{Method: "PATCH", Path: "/api/modules/{id}", Handler: handleUpdateModule(db)},
{Method: "DELETE", Path: "/api/modules/{id}", Handler: handleDeleteModule(db)},
{Method: "GET", Path: "/api/modules/{id}/logs", Handler: handleModuleLogs(db)},
{Method: "POST", Path: "/api/modules/{id}/test", Handler: handleTestModule(db, dispatcher)},
// Per-card Jira sync state (indicator + tooltip).
{Method: "GET", Path: "/api/cards/{id}/jira-sync", Handler: handleCardJiraSync(db, dispatcher)},
// Jira import: list issues not yet in kanban + bulk import.
{Method: "GET", Path: "/api/jira/issues", Handler: handleListJiraIssues(db)},
{Method: "POST", Path: "/api/jira/import", Handler: handleImportJiraIssues(db)},
// Jira column-sync check: detect drift between kanban col ↔ Jira status.
{Method: "GET", Path: "/api/jira/check-columns", Handler: handleCheckJiraColumns(db)},
{Method: "POST", Path: "/api/jira/reconcile-columns", Handler: handleReconcileJiraColumns(db)},
}
}
// GET /api/version → {"version": "<semver>"}
//
// Public, no auth. Skipped from session middleware via skip list updated in
// main.go to keep the SPA pre-login able to display the running build.
func handleVersion() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
infra.HTTPJSONResponse(w, http.StatusOK, map[string]string{"version": Version})
}
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"fn-registry/functions/infra"
)
const internalTokenHeader = "X-Internal-Token"
// generateInternalToken returns a 32-byte hex token used by the kanban-mcp
// subprocess to call back into /api/tool/{name}. Generated fresh per process.
func generateInternalToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("rand.Read: " + err.Error())
}
return hex.EncodeToString(b)
}
// handleInternalTool exposes executeTool via HTTP for the MCP subprocess.
// Auth: shared internal token in X-Internal-Token header. Constant-time compare.
func handleInternalTool(db *DB, expectedToken string, logger *ChatLogger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
got := r.Header.Get(internalTokenHeader)
if subtle.ConstantTimeCompare([]byte(got), []byte(expectedToken)) != 1 {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "invalid internal token"})
return
}
name := r.PathValue("name")
if name == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: "tool name required"})
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: err.Error()})
return
}
if len(body) == 0 {
body = []byte("{}")
}
input := json.RawMessage(body)
if err := validateToolName(name); err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusNotFound, Code: "unknown_tool", Message: err.Error()})
return
}
res := executeTool(db, name, input)
if logger != nil {
logger.Log(name, input, res)
}
// Always 200 — MCP-side maps res.OK to MCP isError.
infra.HTTPJSONResponse(w, http.StatusOK, res)
}
}
+569
View File
@@ -0,0 +1,569 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"fn-registry/functions/infra"
)
// jiraImportRequest is the body shape for POST /api/jira/import.
type jiraImportRequest struct {
IssueKeys []string `json:"issue_keys"`
FallbackColumnID string `json:"fallback_column_id"` // optional: where to land issues whose status has no kanban mapping
}
// jiraIssueOut is what we return in GET /api/jira/issues for the frontend
// import picker. We deliberately keep this small — clicking a row redirects
// to Jira for full detail.
type jiraIssueOut struct {
Key string `json:"key"`
Summary string `json:"summary"`
StatusName string `json:"status_name"`
IssueType string `json:"issue_type"`
Assignee string `json:"assignee"`
Updated string `json:"updated"`
URL string `json:"url"`
AlreadyImported bool `json:"already_imported"`
MappedColumnID string `json:"mapped_column_id,omitempty"`
IssueTypeIcon string `json:"issue_type_icon,omitempty"`
}
// linkedCardForCheck is the projection used by the check-columns endpoint.
// We only need fields visible in the report table.
type linkedCardForCheck struct {
ID string
Title string
JiraKey string
ColumnID string
ColumnName string
}
func listLinkedCardsForCheck(db *DB) ([]linkedCardForCheck, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.title, c.jira_key, c.column_id, col.name
FROM cards c
JOIN columns col ON col.id = c.column_id
WHERE c.jira_key != ''
AND c.deleted_at IS NULL
ORDER BY c.jira_key ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []linkedCardForCheck{}
for rows.Next() {
var c linkedCardForCheck
if err := rows.Scan(&c.ID, &c.Title, &c.JiraKey, &c.ColumnID, &c.ColumnName); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// activeJiraModule returns the first enabled Jira module + its decoded config,
// or an error if no module is configured. The handlers below need both the
// credentials and the status_map to operate.
func activeJiraModule(db *DB) (Module, jiraConfig, error) {
mods, err := db.listModulesEnabled()
if err != nil {
return Module{}, jiraConfig{}, err
}
for _, m := range mods {
if m.Kind != "jira" {
continue
}
cfg, perr := parseJiraConfig(m)
if perr != nil {
return Module{}, jiraConfig{}, perr
}
return m, cfg, nil
}
return Module{}, jiraConfig{}, fmt.Errorf("no enabled jira module configured")
}
// jiraGET performs an authenticated GET against the configured Jira API and
// decodes the JSON response into out.
func jiraGET(ctx context.Context, cfg jiraConfig, path string, out interface{}) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.BaseURL+path, nil)
if err != nil {
return 0, err
}
req.Header.Set("Accept", "application/json")
if cfg.Email != "" && cfg.APIToken != "" {
basic := base64.StdEncoding.EncodeToString([]byte(cfg.Email + ":" + cfg.APIToken))
req.Header.Set("Authorization", "Basic "+basic)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode >= 400 {
return resp.StatusCode, fmt.Errorf("jira GET %s: %d %s", path, resp.StatusCode, truncate(body, 240))
}
if out != nil {
if err := json.Unmarshal(body, out); err != nil {
return resp.StatusCode, fmt.Errorf("decode %s: %w", path, err)
}
}
return resp.StatusCode, nil
}
// extractADFText walks an Atlassian Document Format JSON node and collects the
// text content. Returns "" when the input is not a valid ADF doc. Used to
// pre-populate the kanban card description on import — operators can edit it
// later, the Jira link is the source of truth for rich content.
func extractADFText(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var node struct {
Type string `json:"type"`
Text string `json:"text"`
Content []json.RawMessage `json:"content"`
}
if err := json.Unmarshal(raw, &node); err != nil {
return ""
}
var buf bytes.Buffer
collectADFText(&buf, node.Type, node.Text, node.Content)
return strings.TrimSpace(buf.String())
}
func collectADFText(buf *bytes.Buffer, nodeType, text string, content []json.RawMessage) {
if text != "" {
buf.WriteString(text)
}
for _, c := range content {
var inner struct {
Type string `json:"type"`
Text string `json:"text"`
Content []json.RawMessage `json:"content"`
}
if err := json.Unmarshal(c, &inner); err != nil {
continue
}
collectADFText(buf, inner.Type, inner.Text, inner.Content)
}
// Paragraph / list-item / heading boundaries get a newline so the result
// is roughly readable in the kanban card body.
switch nodeType {
case "paragraph", "heading", "listItem", "bulletList", "orderedList":
buf.WriteString("\n")
}
}
// reverseStatusMap inverts the kanban-col-name -> jira-status-name mapping so
// importers can land an issue in the column whose status matches. Lower-cased
// keys for case-insensitive lookup.
func reverseStatusMap(cfg jiraConfig) map[string]string {
out := make(map[string]string, len(cfg.StatusMap))
for col, status := range cfg.StatusMap {
out[strings.ToLower(status)] = col
}
return out
}
// handleListJiraIssues fetches up to `limit` issues from the configured Jira
// board and annotates each with whether it is already imported into kanban
// and (if mappable) the kanban column it would land in.
func handleListJiraIssues(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
_, cfg, err := activeJiraModule(db)
if err != nil {
badRequest(w, err.Error())
return
}
if cfg.BoardID <= 0 {
badRequest(w, "module is missing board_id")
return
}
limit := 100
if v := r.URL.Query().Get("limit"); v != "" {
if n, perr := strconv.Atoi(v); perr == nil && n > 0 && n <= 200 {
limit = n
}
}
showImported := r.URL.Query().Get("include_imported") == "true"
q := url.Values{}
q.Set("maxResults", strconv.Itoa(limit))
q.Set("fields", "summary,status,assignee,updated,issuetype,description")
path := fmt.Sprintf("/rest/agile/1.0/board/%d/issue?%s", cfg.BoardID, q.Encode())
var page struct {
Issues []struct {
Key string `json:"key"`
Fields struct {
Summary string `json:"summary"`
Status struct {
Name string `json:"name"`
} `json:"status"`
Assignee *struct {
DisplayName string `json:"displayName"`
} `json:"assignee"`
Updated string `json:"updated"`
IssueType struct {
Name string `json:"name"`
IconURL string `json:"iconUrl"`
} `json:"issuetype"`
} `json:"fields"`
} `json:"issues"`
}
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout)
defer cancel()
if _, err := jiraGET(ctx, cfg, path, &page); err != nil {
serverError(w, err)
return
}
// Build lookup: jira_key -> bool (already imported)
importedKeys, err := db.listImportedJiraKeys()
if err != nil {
serverError(w, err)
return
}
colByName, err := db.listColumnsByName()
if err != nil {
serverError(w, err)
return
}
statusToCol := reverseStatusMap(cfg)
out := make([]jiraIssueOut, 0, len(page.Issues))
for _, iss := range page.Issues {
already := importedKeys[iss.Key]
if already && !showImported {
continue
}
row := jiraIssueOut{
Key: iss.Key,
Summary: iss.Fields.Summary,
StatusName: iss.Fields.Status.Name,
IssueType: iss.Fields.IssueType.Name,
Updated: iss.Fields.Updated,
URL: cfg.BaseURL + "/browse/" + iss.Key,
AlreadyImported: already,
IssueTypeIcon: iss.Fields.IssueType.IconURL,
}
if iss.Fields.Assignee != nil {
row.Assignee = iss.Fields.Assignee.DisplayName
}
if colName, ok := statusToCol[strings.ToLower(iss.Fields.Status.Name)]; ok {
if col, ok := colByName[colName]; ok {
row.MappedColumnID = col.ID
}
}
out = append(out, row)
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
"issues": out,
"board_id": cfg.BoardID,
"project_key": cfg.ProjectKey,
"status_to_column": statusToCol,
"include_imported": showImported,
})
})
}
// jiraCheckRow is one row of the check-columns report.
type jiraCheckRow struct {
CardID string `json:"card_id"`
JiraKey string `json:"jira_key"`
Title string `json:"title"`
KanbanColumnID string `json:"kanban_column_id"`
KanbanColumnName string `json:"kanban_column_name"`
JiraStatusName string `json:"jira_status_name"`
ExpectedKanbanCol string `json:"expected_kanban_col"` // kanban col that matches the current Jira status (reverse status_map)
ExpectedJiraStat string `json:"expected_jira_status"` // jira status that matches the current kanban col (status_map)
Mismatch bool `json:"mismatch"`
IssueURL string `json:"issue_url"`
}
// handleCheckJiraColumns walks every linked card, fetches its current Jira
// status, and reports whether the kanban column ↔ Jira status mapping is in
// sync. Used by the "Comprobar columnas" tab in the Jira modal.
//
// Performance note: one Jira REST call per linked card. With 127 cards that
// is ~127 round-trips — slow (≈30s end-to-end) but tolerable as an admin op.
// A future optimisation could batch via /search/jql with key IN (...) and a
// fields=status projection.
func handleCheckJiraColumns(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
_, cfg, err := activeJiraModule(db)
if err != nil {
badRequest(w, err.Error())
return
}
cards, err := listLinkedCardsForCheck(db)
if err != nil {
serverError(w, err)
return
}
statusToCol := reverseStatusMap(cfg)
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*5)
defer cancel()
rows := make([]jiraCheckRow, 0, len(cards))
var mismatches int
for _, c := range cards {
var iss struct {
Fields struct {
Status struct {
Name string `json:"name"`
} `json:"status"`
} `json:"fields"`
}
if _, err := jiraGET(ctx, cfg, "/rest/api/3/issue/"+url.PathEscape(c.JiraKey)+"?fields=status", &iss); err != nil {
rows = append(rows, jiraCheckRow{
CardID: c.ID,
JiraKey: c.JiraKey,
Title: c.Title,
KanbanColumnID: c.ColumnID,
KanbanColumnName: c.ColumnName,
JiraStatusName: "(fetch failed: " + err.Error() + ")",
Mismatch: true,
IssueURL: cfg.BaseURL + "/browse/" + c.JiraKey,
})
mismatches++
continue
}
expectedCol := statusToCol[strings.ToLower(iss.Fields.Status.Name)]
expectedStat := cfg.StatusMap[c.ColumnName]
mm := !strings.EqualFold(iss.Fields.Status.Name, expectedStat)
if mm {
mismatches++
}
rows = append(rows, jiraCheckRow{
CardID: c.ID,
JiraKey: c.JiraKey,
Title: c.Title,
KanbanColumnID: c.ColumnID,
KanbanColumnName: c.ColumnName,
JiraStatusName: iss.Fields.Status.Name,
ExpectedKanbanCol: expectedCol,
ExpectedJiraStat: expectedStat,
Mismatch: mm,
IssueURL: cfg.BaseURL + "/browse/" + c.JiraKey,
})
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
"rows": rows,
"total": len(rows),
"mismatches": mismatches,
"in_sync": len(rows) - mismatches,
"status_map": cfg.StatusMap,
"reverse_map": statusToCol,
})
})
}
// reconcileRequest is the body shape for POST /api/jira/reconcile-columns.
// direction=kanban-wins → push Jira to match kanban (the only mode for now;
// reverse is risky because moving cards in kanban can trigger downstream
// notifications/timers).
type reconcileRequest struct {
CardIDs []string `json:"card_ids"`
Direction string `json:"direction"` // currently only "kanban-wins"
}
// handleReconcileJiraColumns transitions each requested issue so its status
// matches the current kanban column (kanban as source of truth). Reuses
// the dispatcher's transitionToStatus helper for consistency with the
// regular card.moved path. Per-card result.
func handleReconcileJiraColumns(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
var body reconcileRequest
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if len(body.CardIDs) == 0 {
badRequest(w, "card_ids required")
return
}
if body.Direction == "" {
body.Direction = "kanban-wins"
}
if body.Direction != "kanban-wins" {
badRequest(w, "only direction=kanban-wins is supported")
return
}
_, cfg, err := activeJiraModule(db)
if err != nil {
badRequest(w, err.Error())
return
}
h := &jiraHandler{}
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*time.Duration(len(body.CardIDs)+1))
defer cancel()
results := make([]map[string]interface{}, 0, len(body.CardIDs))
for _, cid := range body.CardIDs {
res := map[string]interface{}{"card_id": cid}
card, cerr := db.getCardForJira(cid)
if cerr != nil {
res["status"] = "error"
res["error"] = cerr.Error()
results = append(results, res)
continue
}
if card.JiraKey == "" {
res["status"] = "skipped"
res["error"] = "card has no jira_key"
results = append(results, res)
continue
}
if _, ok := cfg.StatusMap[card.ColumnName]; !ok {
res["status"] = "skipped"
res["error"] = "no status_map entry for column " + card.ColumnName
results = append(results, res)
continue
}
status, terr := h.transitionToStatus(ctx, cfg, card.JiraKey, card.ColumnName)
if terr != nil {
res["status"] = "error"
res["error"] = terr.Error()
res["http"] = status
results = append(results, res)
continue
}
now := time.Now().UTC().Format(time.RFC3339)
_ = db.updateCardJiraSync(cid, cfg.StatusMap[card.ColumnName], now, "")
res["status"] = "fixed"
res["jira_key"] = card.JiraKey
res["jira_status"] = cfg.StatusMap[card.ColumnName]
results = append(results, res)
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{"results": results})
})
}
// handleImportJiraIssues creates a kanban card for each requested issue_key
// and links it to the existing Jira issue (sets jira_key directly, so the
// dispatcher will treat any future kanban edits as updates instead of trying
// to create a duplicate). The card lands in the column whose status_map entry
// matches the issue's current status; falls back to FallbackColumnID when
// unmappable.
func handleImportJiraIssues(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
uid, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
var body jiraImportRequest
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if len(body.IssueKeys) == 0 {
badRequest(w, "issue_keys required")
return
}
_, cfg, err := activeJiraModule(db)
if err != nil {
badRequest(w, err.Error())
return
}
colByName, err := db.listColumnsByName()
if err != nil {
serverError(w, err)
return
}
statusToCol := reverseStatusMap(cfg)
results := make([]map[string]interface{}, 0, len(body.IssueKeys))
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*time.Duration(len(body.IssueKeys)+1))
defer cancel()
for _, key := range body.IssueKeys {
res := map[string]interface{}{"key": key}
// Skip if already imported.
if existing, _ := db.findCardByJiraKey(key); existing != "" {
res["status"] = "skipped"
res["error"] = "already imported (card " + existing + ")"
results = append(results, res)
continue
}
// Fetch issue detail to get summary + description + status.
var iss struct {
Fields struct {
Summary string `json:"summary"`
Status struct {
Name string `json:"name"`
} `json:"status"`
Description json.RawMessage `json:"description"`
Assignee *struct {
DisplayName string `json:"displayName"`
} `json:"assignee"`
} `json:"fields"`
}
if _, err := jiraGET(ctx, cfg, "/rest/api/3/issue/"+url.PathEscape(key), &iss); err != nil {
res["status"] = "error"
res["error"] = err.Error()
results = append(results, res)
continue
}
// Determine target column.
columnID := body.FallbackColumnID
if colName, ok := statusToCol[strings.ToLower(iss.Fields.Status.Name)]; ok {
if col, ok := colByName[colName]; ok {
columnID = col.ID
}
}
if columnID == "" {
res["status"] = "error"
res["error"] = fmt.Sprintf("no column mapping for status %q and no fallback_column_id", iss.Fields.Status.Name)
results = append(results, res)
continue
}
requester := ""
if iss.Fields.Assignee != nil {
requester = iss.Fields.Assignee.DisplayName
}
description := extractADFText(iss.Fields.Description)
if description == "" {
description = "Imported from Jira " + key
} else {
description = description + "\n\n— Imported from Jira " + key
}
card, cerr := db.CreateCard(columnID, requester, iss.Fields.Summary, description, uid)
if cerr != nil {
res["status"] = "error"
res["error"] = cerr.Error()
results = append(results, res)
continue
}
// Link to existing Jira issue + seed sync state so the indicator
// renders green immediately.
if err := db.setCardJiraKey(card.ID, key); err != nil {
res["status"] = "error"
res["error"] = "card created but link failed: " + err.Error()
results = append(results, res)
continue
}
now := time.Now().UTC().Format(time.RFC3339)
_ = db.updateCardJiraSync(card.ID, iss.Fields.Status.Name, now, "")
res["status"] = "imported"
res["card_id"] = card.ID
res["column_id"] = columnID
results = append(results, res)
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
"results": results,
})
})
}
+99 -3
View File
@@ -21,13 +21,75 @@ import (
//go:embed all:dist
var frontendDist embed.FS
// Version is the build-time identifier of the kanban app. It is injected
// from app.md's `version:` field via -ldflags "-X main.Version=..." by run.sh
// (and by docker/CI). Defaults to "dev" for hand-built binaries.
var Version = "dev"
func main() {
// Subcommand `kanban mcp` runs as MCP server over stdio (spawned by claude -p).
if len(os.Args) > 1 && os.Args[1] == "mcp" {
if err := runMCPServer(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "kanban mcp: %v\n", err)
os.Exit(1)
}
return
}
// Subcommand `kanban mint-token` issues an HTTP MCP bearer token for a user.
if len(os.Args) > 1 && os.Args[1] == "mint-token" {
if err := runMintToken(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "kanban mint-token: %v\n", err)
os.Exit(1)
}
return
}
// Subcommand `kanban seed-jira-data` provisions the Jira push module
// scoped to project DATA + board 33 using pass-stored credentials.
if len(os.Args) > 1 && os.Args[1] == "seed-jira-data" {
if err := runSeedJiraData(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "kanban seed-jira-data: %v\n", err)
os.Exit(1)
}
return
}
// Subcommand `kanban backfill-jira` mirrors every active kanban card that
// is not yet linked to a Jira issue into Jira, in batches.
if len(os.Args) > 1 && os.Args[1] == "backfill-jira" {
if err := runBackfillJira(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "kanban backfill-jira: %v\n", err)
os.Exit(1)
}
return
}
// Subcommand `kanban resync-jira-fields` patches existing linked issues
// so their issuetype/assignee/labels reflect the current module config.
if len(os.Args) > 1 && os.Args[1] == "resync-jira-fields" {
if err := runResyncJiraFields(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "kanban resync-jira-fields: %v\n", err)
os.Exit(1)
}
return
}
flags := flag.NewFlagSet("kanban", flag.ExitOnError)
port := flags.Int("port", 8095, "HTTP port")
dbPath := flags.String("db", "operations.db", "SQLite database path")
initialAdmin := flags.String("initial-admin", os.Getenv("KANBAN_INITIAL_ADMIN"), "Bootstrap admin in user:pass form (only if no users yet)")
flagsPath := flags.String("flags", "dev/feature_flags.json", "Feature flags JSON path (missing file → all disabled)")
flags.Parse(os.Args[1:])
featureFlags, err := loadFeatureFlags(*flagsPath)
if err != nil {
log.Fatalf("load feature flags: %v", err)
}
for name, fl := range featureFlags.Flags {
log.Printf("feature flag %q enabled=%v", name, fl.Enabled)
}
db, err := openDB(*dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
@@ -37,10 +99,21 @@ func main() {
bootstrapAdmin(db, *initialAdmin)
startSessionCleanup(db)
internalToken := os.Getenv("KANBAN_INTERNAL_TOKEN")
if internalToken == "" {
internalToken = generateInternalToken()
}
wd := chatWorkdir(*dbPath)
logger := newChatLogger(filepath.Join(wd, "chat.log"))
log.Printf("chat tool log: %s", logger.path)
mux := infra.HTTPRouter(apiRoutes(db, wd, logger))
hub := NewEventHub()
dispatcher := NewDispatcher(db, hub)
dispatcher.Start()
defer dispatcher.Stop()
mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken, &featureFlags, hub, dispatcher))
mux.Handle("/mcp", mcpHTTPHandler(db))
feHandler := frontendHandler()
if feHandler != nil {
@@ -53,7 +126,7 @@ func main() {
authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
DB: db.conn,
CookieName: cookieName,
SkipPaths: []string{"/api/auth/", "/health", "/assets/", "/index.html"},
SkipPaths: []string{"/api/auth/", "/api/tool/", "/api/flags", "/api/version", "/health", "/assets/", "/index.html"},
UserCtxKey: userCtxKey,
})
@@ -140,5 +213,28 @@ func frontendHandler() http.Handler {
if len(entries) == 0 {
return nil
}
return infra.SPAHandler(sub, "index.html")
return cacheHeadersMiddleware(infra.SPAHandler(sub, "index.html"))
}
// cacheHeadersMiddleware ensures the SPA shell is never cached while the
// hashed assets (which are content-addressed by Vite) are cached for a long
// time. Without this, browsers happily reuse an old index.html — pinned to a
// stale /assets/index-<hash>.js URL — and never pick up new releases.
//
// Policy:
//
// /assets/* → public, max-age=1y, immutable (filename changes per build)
// everything else → no-store, must-revalidate (forces revalidation on every
// navigation so the latest hash is always discovered)
func cacheHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
next.ServeHTTP(w, r)
})
}
+353
View File
@@ -0,0 +1,353 @@
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"fn-registry/functions/infra"
)
// runMCPServer is the entry point for the `kanban mcp` subcommand. It runs
// stdio JSON-RPC and forwards each tool call to the kanban backend's
// /api/tool/{name} endpoint, authenticated with a shared internal token.
//
// Required env vars (set by the parent kanban process when generating mcp.json):
// KANBAN_BACKEND_URL — e.g. http://127.0.0.1:8095
// KANBAN_INTERNAL_TOKEN — token to send in X-Internal-Token header
func runMCPServer(args []string) error {
fs := flag.NewFlagSet("kanban mcp", flag.ContinueOnError)
urlFlag := fs.String("url", os.Getenv("KANBAN_BACKEND_URL"), "kanban backend URL")
tokenFlag := fs.String("token", os.Getenv("KANBAN_INTERNAL_TOKEN"), "internal token")
if err := fs.Parse(args); err != nil {
return err
}
if *urlFlag == "" {
return fmt.Errorf("--url or KANBAN_BACKEND_URL required")
}
if *tokenFlag == "" {
return fmt.Errorf("--token or KANBAN_INTERNAL_TOKEN required")
}
httpClient := &http.Client{Timeout: 30 * time.Second}
tools := mcpToolDefs()
handler := func(ctx context.Context, name string, input json.RawMessage) (any, bool, error) {
body := []byte(input)
if len(body) == 0 {
body = []byte("{}")
}
req, err := http.NewRequestWithContext(ctx, "POST", *urlFlag+"/api/tool/"+name, bytes.NewReader(body))
if err != nil {
return nil, false, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, *tokenFlag)
resp, err := httpClient.Do(req)
if err != nil {
return nil, false, err
}
defer resp.Body.Close()
buf, err := io.ReadAll(resp.Body)
if err != nil {
return nil, false, err
}
if resp.StatusCode >= 500 {
return nil, false, fmt.Errorf("backend %d: %s", resp.StatusCode, string(buf))
}
// 4xx and 2xx both serialize as ToolResult JSON. Decode and map.
var tr ToolResult
if err := json.Unmarshal(buf, &tr); err != nil {
// Non-ToolResult body (e.g. unauthorized error envelope from infra).
return string(buf), resp.StatusCode >= 400, nil
}
if !tr.OK {
return tr.Error, true, nil
}
return tr.Result, false, nil
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
return infra.ServeMCP(ctx, infra.MCPServerOpts{
Name: "kanban",
Version: "1.0.0",
Tools: tools,
Handler: handler,
In: os.Stdin,
Out: os.Stdout,
Logger: os.Stderr,
})
}
// mcpToolDefs returns the JSON-Schema definitions for every kanban tool.
// Names match the executeTool dispatch table in tools.go.
func mcpToolDefs() []infra.MCPToolDef {
return []infra.MCPToolDef{
{
Name: "list_board",
Description: "Lista columnas y tarjetas del tablero. Sin argumentos. Devuelve {columns, cards}.",
InputSchema: rawSchema(map[string]any{"type": "object", "properties": map[string]any{}}),
},
{
Name: "create_column",
Description: "Crea una columna nueva. Devuelve la columna creada con su id.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string", "description": "Nombre de la columna"},
},
"required": []string{"name"},
}),
},
{
Name: "update_column",
Description: "Modifica una columna existente. Pasa al menos uno: name, location ('board'|'sidebar'), width (200..800 px), wip_limit (0=sin limite), is_done (terminal: cards cuentan como completadas).",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"name": map[string]any{"type": "string"},
"location": map[string]any{"type": "string", "enum": []string{"board", "sidebar"}},
"width": map[string]any{"type": "integer"},
"wip_limit": map[string]any{"type": "integer"},
"is_done": map[string]any{"type": "boolean"},
},
"required": []string{"id"},
}),
},
{
Name: "rename_column",
Description: "Alias de update_column con solo {id, name}.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"name": map[string]any{"type": "string"},
},
"required": []string{"id", "name"},
}),
},
{
Name: "delete_column",
Description: "Elimina una columna y todas sus tarjetas (las envia a la papelera).",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
},
"required": []string{"id"},
}),
},
{
Name: "reorder_columns",
Description: "Reordena columnas. ids es el array completo de columnas en el nuevo orden.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
},
"required": []string{"ids"},
}),
},
{
Name: "create_card",
Description: "Crea una tarjeta en una columna. column_id y title obligatorios.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"column_id": map[string]any{"type": "string"},
"requester": map[string]any{"type": "string"},
"title": map[string]any{"type": "string"},
"description": map[string]any{"type": "string"},
},
"required": []string{"column_id", "title"},
}),
},
{
Name: "update_card",
Description: "Edita campos de una tarjeta. Color: blue|teal|violet|pink|orange|green|yellow|red|''. locked bloquea movimiento. assignee_id null para desasignar.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"requester": map[string]any{"type": "string"},
"title": map[string]any{"type": "string"},
"description": map[string]any{"type": "string"},
"color": map[string]any{"type": "string"},
"locked": map[string]any{"type": "boolean"},
"assignee_id": map[string]any{"type": []string{"string", "null"}},
},
"required": []string{"id"},
}),
},
{
Name: "delete_card",
Description: "Envia una tarjeta a la papelera.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
},
"required": []string{"id"},
}),
},
{
Name: "move_card",
Description: "Mueve una tarjeta a otra columna. Si omites ordered_ids, se anade al final.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"column_id": map[string]any{"type": "string"},
"ordered_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
},
"required": []string{"id", "column_id"},
}),
},
{
Name: "card_history",
Description: "Devuelve el historial de cambios de una tarjeta.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
},
"required": []string{"id"},
}),
},
{
Name: "find_cards",
Description: "Busca tarjetas. query (texto en title/description/requester), column_id (filtra por columna), requester (filtra por solicitante).",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string"},
"column_id": map[string]any{"type": "string"},
"requester": map[string]any{"type": "string"},
},
}),
},
{
Name: "list_users",
Description: "Lista usuarios disponibles para asignar tarjetas.",
InputSchema: rawSchema(map[string]any{"type": "object", "properties": map[string]any{}}),
},
{
Name: "assign_card",
Description: "Asigna o desasigna una tarjeta. assignee_id null para desasignar.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"assignee_id": map[string]any{"type": []string{"string", "null"}},
},
"required": []string{"id"},
}),
},
{
Name: "add_comment",
Description: "Anade un comentario (card_message) a una tarjeta. Requiere card_id, body y autor (author_id o author_username). Devuelve el CardMessage creado.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"card_id": map[string]any{"type": "string"},
"body": map[string]any{"type": "string"},
"author_id": map[string]any{"type": "string"},
"author_username": map[string]any{"type": "string"},
},
"required": []string{"card_id", "body"},
}),
},
{
Name: "list_comments",
Description: "Lista los comentarios (card_messages) de una tarjeta en orden cronologico.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"card_id": map[string]any{"type": "string"},
},
"required": []string{"card_id"},
}),
},
{
Name: "delete_comment",
Description: "Borra un comentario propio. Solo el autor original puede borrar (validado en server). " +
"Requiere autenticacion via MCP HTTP — el actor se infiere del bearer token. " +
"Output: {ok:true}.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "ID del card_message a borrar (no de la card)."},
},
"required": []string{"id"},
}),
},
{
Name: "get_card",
Description: "Devuelve una tarjeta activa (no archivada) por id o por seq_num. Read-only. " +
"Pasa exactamente UNO de los dos: id (hash interno) o seq_num (entero visible, ej. la '115' de 'card 00115'). " +
"Output: Card completa con time_in_column_ms, total_locked_ms, tags, stickers, deadline.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "ID hash de la tarjeta (16 hex)."},
"seq_num": map[string]any{"type": "integer", "description": "Numero secuencial visible al usuario."},
},
}),
},
}
}
func rawSchema(s map[string]any) json.RawMessage {
b, err := json.Marshal(s)
if err != nil {
panic(err)
}
return b
}
// writeMCPConfig writes a temporary mcp.json that points to this binary's
// `mcp` subcommand with the given URL and token. Returns the absolute path of
// the file created. Caller is responsible for removing it.
func writeMCPConfig(binPath, backendURL, token string) (string, error) {
cfg := map[string]any{
"mcpServers": map[string]any{
"kanban": map[string]any{
"command": binPath,
"args": []string{"mcp"},
"env": map[string]string{
"KANBAN_BACKEND_URL": backendURL,
"KANBAN_INTERNAL_TOKEN": token,
},
},
},
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", err
}
f, err := os.CreateTemp("", "kanban-mcp-*.json")
if err != nil {
return "", err
}
if _, err := f.Write(b); err != nil {
f.Close()
os.Remove(f.Name())
return "", err
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"fn-registry/functions/infra"
)
// mcpHTTPHandler builds the http.Handler that serves the MCP Streamable HTTP
// transport for remote Claude clients. Bearer-auth backed by the mcp_tokens
// table; tool dispatch reuses executeToolAs() so per-user tools (add_comment,
// delete_comment) can infer the actor from the authenticated token.
func mcpHTTPHandler(db *DB) http.Handler {
auth := func(r *http.Request) (context.Context, error) {
header := r.Header.Get("Authorization")
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if token == "" || token == header {
return nil, errors.New("missing bearer token")
}
userID, err := db.LookupMCPToken(token)
if err != nil {
return nil, err
}
if userID == "" {
return nil, errors.New("invalid or revoked token")
}
return context.WithValue(r.Context(), userCtxKey, userID), nil
}
handler := func(ctx context.Context, name string, input json.RawMessage) (any, bool, error) {
body := input
if len(body) == 0 {
body = json.RawMessage(`{}`)
}
actor, _ := infra.UserIDFromContext(ctx, userCtxKey)
res := executeToolAs(db, name, body, actor)
if !res.OK {
return res.Error, true, nil
}
return res.Result, false, nil
}
return infra.MCPHTTPHandler(infra.MCPHTTPOpts{
Name: "kanban",
Version: Version,
Tools: mcpToolDefs(),
Handler: handler,
Auth: auth,
Logger: os.Stderr,
})
}
+174
View File
@@ -0,0 +1,174 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"flag"
"fmt"
)
// MCPToken is a per-user access token used by remote Claude clients to talk to
// the kanban MCP HTTP endpoint. The plaintext value is shown ONCE at creation
// time; we only persist the SHA-256 hash.
type MCPToken struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
LastUsedAt *string `json:"last_used_at,omitempty"`
}
const mcpTokenPrefix = "kmcp_"
var errMCPTokenNotFound = errors.New("mcp token not found")
// MintMCPToken creates a new active token for userID and returns the plaintext
// value (caller must surface it to the user immediately; it cannot be
// recovered later) along with the row metadata.
func (db *DB) MintMCPToken(userID, name string) (string, *MCPToken, error) {
if userID == "" {
return "", nil, fmt.Errorf("user_id required")
}
plaintext, err := generateMCPTokenPlaintext()
if err != nil {
return "", nil, fmt.Errorf("generate token: %w", err)
}
tok := &MCPToken{
ID: newID(),
Name: name,
CreatedAt: nowRFC3339(),
}
_, err = db.conn.Exec(
`INSERT INTO mcp_tokens (id, user_id, token_hash, name, created_at) VALUES (?, ?, ?, ?, ?)`,
tok.ID, userID, hashMCPToken(plaintext), tok.Name, tok.CreatedAt,
)
if err != nil {
return "", nil, err
}
return plaintext, tok, nil
}
func (db *DB) ListMCPTokens(userID string) ([]MCPToken, error) {
rows, err := db.conn.Query(
`SELECT id, name, created_at, last_used_at FROM mcp_tokens
WHERE user_id=? AND revoked_at IS NULL
ORDER BY created_at DESC`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []MCPToken{}
for rows.Next() {
var t MCPToken
var lastUsed sql.NullString
if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &lastUsed); err != nil {
return nil, err
}
if lastUsed.Valid {
t.LastUsedAt = &lastUsed.String
}
out = append(out, t)
}
return out, rows.Err()
}
// RevokeMCPToken sets revoked_at on the token belonging to userID. Returns
// errMCPTokenNotFound if no active row matches.
func (db *DB) RevokeMCPToken(userID, tokenID string) error {
res, err := db.conn.Exec(
`UPDATE mcp_tokens SET revoked_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL`,
nowRFC3339(), tokenID, userID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return errMCPTokenNotFound
}
return nil
}
// LookupMCPToken hashes plaintext and returns the owning user_id if the token
// is active. Updates last_used_at as a side effect. Returns "" + nil when the
// token does not match an active row.
func (db *DB) LookupMCPToken(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
hash := hashMCPToken(plaintext)
var userID, id string
err := db.conn.QueryRow(
`SELECT id, user_id FROM mcp_tokens WHERE token_hash=? AND revoked_at IS NULL`, hash,
).Scan(&id, &userID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
if _, err := db.conn.Exec(`UPDATE mcp_tokens SET last_used_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
return userID, fmt.Errorf("touch last_used_at: %w", err)
}
return userID, nil
}
func hashMCPToken(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(sum[:])
}
func generateMCPTokenPlaintext() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return mcpTokenPrefix + hex.EncodeToString(b), nil
}
// runMintToken implements `kanban mint-token --user <id> --name <pc>`.
// Generates a fresh token, persists its sha256 in mcp_tokens, and prints the
// plaintext ONCE to stdout. The caller must save it — the server keeps only
// the hash.
func runMintToken(args []string) error {
fs := flag.NewFlagSet("kanban mint-token", flag.ContinueOnError)
dbPath := fs.String("db", "operations.db", "SQLite database path")
userID := fs.String("user", "", "owner user_id (must exist in users table)")
name := fs.String("name", "", "label for this token (e.g. PC name)")
if err := fs.Parse(args); err != nil {
return err
}
if *userID == "" || *name == "" {
return fmt.Errorf("--user and --name required")
}
db, err := openDB(*dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
var exists int
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users WHERE id=?`, *userID).Scan(&exists); err != nil {
return fmt.Errorf("user lookup: %w", err)
}
if exists == 0 {
return fmt.Errorf("user %q not found", *userID)
}
plaintext, tok, err := db.MintMCPToken(*userID, *name)
if err != nil {
return fmt.Errorf("mint: %w", err)
}
fmt.Printf("token id: %s\n", tok.ID)
fmt.Printf("name: %s\n", tok.Name)
fmt.Printf("created_at: %s\n", tok.CreatedAt)
fmt.Printf("\ntoken (save now, will not be shown again):\n%s\n", plaintext)
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"errors"
"net/http"
"strings"
"fn-registry/functions/infra"
)
// POST /api/mcp-tokens {name}
//
// Mints a new MCP token for the current user. The plaintext token is returned
// ONLY in this response — there is no way to retrieve it again.
func handleCreateMCPToken(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if userID == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "login required"})
return
}
var body struct {
Name string `json:"name"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
name = "default"
}
plaintext, tok, err := db.MintMCPToken(userID, name)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, map[string]any{
"id": tok.ID,
"name": tok.Name,
"created_at": tok.CreatedAt,
"token": plaintext,
})
}
}
// GET /api/mcp-tokens
func handleListMCPTokens(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if userID == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "login required"})
return
}
tokens, err := db.ListMCPTokens(userID)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, tokens)
}
}
// DELETE /api/mcp-tokens/{id}
func handleRevokeMCPToken(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if userID == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "login required"})
return
}
id := r.PathValue("id")
if err := db.RevokeMCPToken(userID, id); err != nil {
if errors.Is(err, errMCPTokenNotFound) {
notFound(w, "token not found")
return
}
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+14
View File
@@ -0,0 +1,14 @@
-- Per-card chat messages (human-to-human comments).
-- Distinct from card_events (which records system events like title_changed)
-- and from /api/chat (which is the board-level LLM chat).
CREATE TABLE IF NOT EXISTS card_messages (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
author_id TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_card_messages_card ON card_messages(card_id, created_at);
@@ -0,0 +1,4 @@
-- Issue 0089: tiempo maximo por columna.
-- NULL/0 = sin limite. >0 = minutos antes de marcar como vencida la card.
-- Cards en columnas con is_done=1 nunca se marcan como vencidas.
ALTER TABLE columns ADD COLUMN max_time_minutes INTEGER NOT NULL DEFAULT 0;
+5
View File
@@ -0,0 +1,5 @@
-- Issue 0092: archivo automatico para cards en columnas Done con +30 dias.
-- archived_at NULL = card activa. archived_at = timestamp ISO = card en cajon.
-- Independiente de deleted_at (papelera): una card puede estar archived sin
-- haber sido borrada. Restaurar = vuelve a su columna original sin deletear.
ALTER TABLE cards ADD COLUMN archived_at TEXT;
@@ -0,0 +1,23 @@
-- Issue 0094: resumen de IA por dia + tabla settings clave/valor.
CREATE TABLE IF NOT EXISTS daily_summaries (
date TEXT PRIMARY KEY,
summary TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL,
generated_by TEXT
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL,
updated_by TEXT
);
INSERT OR IGNORE INTO settings (key, value, updated_at)
VALUES (
'daily_report_prompt',
'Eres un coach de equipo. Resume el reporte diario en un MAXIMO de 4 frases cortas, mencionando: (1) total de tareas hechas y quien destaco, (2) cualquier card reabierta o deadline vencido que merezca atencion, (3) cards estancadas criticas (30+ dias) si las hay, (4) una frase corta de animo o aviso si toca. Tono natural, primera persona del plural, sin emojis. No inventes datos; usa solo los del JSON del reporte.',
datetime('now')
);
+16
View File
@@ -0,0 +1,16 @@
-- Issue 0128: adjuntos de archivos por card.
CREATE TABLE IF NOT EXISTS card_files (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
uploader_id TEXT NOT NULL DEFAULT '',
filename TEXT NOT NULL,
mime TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
stored_path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'upload',
created_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_card_files_card_active
ON card_files(card_id, deleted_at);
+42
View File
@@ -0,0 +1,42 @@
-- Per-user notifications + persisted @mentions.
-- Created by card chat messages (card_messages).
--
-- Kinds:
-- mention — user mentioned via @username in body
-- assigned_chat — user is the card's assignee and someone else commented
-- reply — user previously commented on this card (or is requester)
-- A row is created per (recipient_user, message). The kind chosen is the
-- highest priority among those that apply: mention > assigned_chat > reply.
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
card_id TEXT NOT NULL,
message_id TEXT NOT NULL,
kind TEXT NOT NULL,
actor_id TEXT NOT NULL,
created_at TEXT NOT NULL,
read_at TEXT,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE,
FOREIGN KEY (message_id) REFERENCES card_messages(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
ON notifications(user_id, read_at, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_user_created
ON notifications(user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS card_mentions (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE,
FOREIGN KEY (message_id) REFERENCES card_messages(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_card_mentions_user ON card_mentions(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_card_mentions_card ON card_mentions(card_id);
CREATE INDEX IF NOT EXISTS idx_card_mentions_message ON card_mentions(message_id);
+45
View File
@@ -0,0 +1,45 @@
-- Outbound modules (integrations): kanban events → external systems.
--
-- A module is a configured subscription. The dispatcher (modules.go)
-- subscribes to the EventHub and, for each event whose type matches the
-- module's event_filter, calls the kind-specific handler with the
-- decrypted config.
--
-- Tokens / secrets are encrypted with AES-GCM at rest. The key is derived
-- from the KANBAN_MODULE_KEY environment variable (sha256 of the value).
CREATE TABLE IF NOT EXISTS modules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL, -- 'jira' | 'webhook' | …
enabled INTEGER NOT NULL DEFAULT 1,
event_filter TEXT NOT NULL, -- comma-separated event types
config_cipher BLOB NOT NULL, -- AES-GCM ciphertext of JSON
config_nonce BLOB NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS module_logs (
id TEXT PRIMARY KEY,
module_id TEXT NOT NULL,
event_type TEXT NOT NULL,
card_id TEXT,
status INTEGER, -- HTTP status or 0 if pre-flight
duration_ms INTEGER,
error TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_module_logs_module_created
ON module_logs(module_id, created_at DESC);
-- jira_key: 1:1 link between a kanban card and its Jira issue. Empty
-- string when the card has not yet been synced to Jira.
ALTER TABLE cards ADD COLUMN jira_key TEXT NOT NULL DEFAULT '';
-- is_admin: gates /api/modules access and the Modulos menu item.
-- Bootstrap: egutierrez (the initial admin) is marked admin so the
-- feature is reachable on first deploy. Other users start as non-admin.
ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;
UPDATE users SET is_admin = 1 WHERE username = 'egutierrez';
+26
View File
@@ -0,0 +1,26 @@
-- Per-user MCP access tokens. Users mint tokens from the settings UI and
-- paste them into their local Claude (`claude mcp add --transport http ...`).
-- The plaintext token is shown ONCE at creation time; we only store the hash.
--
-- token_hash is a SHA-256 hex digest of the plaintext token. Lookup on
-- incoming requests: hash the bearer, look up the row, accept if not revoked.
--
-- revoked_at is NULL for active tokens. Tokens are never deleted (audit
-- trail); revocation is a soft delete.
CREATE TABLE IF NOT EXISTS mcp_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_mcp_tokens_user_active
ON mcp_tokens(user_id)
WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_mcp_tokens_hash_active
ON mcp_tokens(token_hash)
WHERE revoked_at IS NULL;
@@ -0,0 +1,13 @@
-- Per-card Jira sync state. Populated by the dispatcher after every push to
-- Jira so the frontend can render an indicator (gray/yellow/green) and a
-- tooltip with the last known status without polling Jira itself.
--
-- jira_last_status: the Jira status name the card was transitioned to in the
-- most recent successful sync (e.g. "In Progress", "Done").
-- jira_last_sync_at: RFC3339 timestamp of the last sync attempt (success or
-- failure).
-- jira_last_error: the error message from the last failed sync, or empty when
-- the last sync succeeded.
ALTER TABLE cards ADD COLUMN jira_last_status TEXT NOT NULL DEFAULT '';
ALTER TABLE cards ADD COLUMN jira_last_sync_at TEXT NOT NULL DEFAULT '';
ALTER TABLE cards ADD COLUMN jira_last_error TEXT NOT NULL DEFAULT '';
+1060
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
)
const moduleKeyEnv = "KANBAN_MODULE_KEY"
// moduleKey derives a 32-byte AES key from the KANBAN_MODULE_KEY env var.
// Returns (key, true) when present; (zero, false) when missing — callers
// must treat that as "module dispatcher disabled".
func moduleKey() ([32]byte, bool) {
v := os.Getenv(moduleKeyEnv)
if v == "" {
return [32]byte{}, false
}
return sha256.Sum256([]byte(v)), true
}
// encryptConfig encrypts a JSON config blob with AES-GCM. Returns the
// ciphertext and the 12-byte nonce. Caller persists both columns.
func encryptConfig(plain []byte) (cipherOut, nonce []byte, err error) {
key, ok := moduleKey()
if !ok {
return nil, nil, fmt.Errorf("%s not set; cannot encrypt module config", moduleKeyEnv)
}
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, err
}
nonce = make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, nil, err
}
cipherOut = gcm.Seal(nil, nonce, plain, nil)
return cipherOut, nonce, nil
}
// decryptConfig is the inverse of encryptConfig.
func decryptConfig(cipherIn, nonce []byte) ([]byte, error) {
key, ok := moduleKey()
if !ok {
return nil, fmt.Errorf("%s not set; cannot decrypt module config", moduleKeyEnv)
}
if len(nonce) == 0 {
return nil, errors.New("nonce empty")
}
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return gcm.Open(nil, nonce, cipherIn, nil)
}
+267
View File
@@ -0,0 +1,267 @@
package main
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"fn-registry/functions/infra"
)
// requireAdmin gates a handler so only users with users.is_admin = 1 can
// reach it. Non-admins get a 403. Anonymous callers get a 401.
func requireAdmin(db *DB, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if uid == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
ok, err := db.IsAdmin(uid)
if err != nil {
serverError(w, err)
return
}
if !ok {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusForbidden, Code: "forbidden", Message: "admin required"})
return
}
next(w, r)
}
}
// publicModule strips secrets out of the config before responding. The
// API token is never returned to the client after it has been stored.
func publicModule(m Module) Module {
clone := m
if clone.Config != nil {
cleaned := JSONValue{}
for k, v := range clone.Config {
if strings.Contains(strings.ToLower(k), "token") || strings.Contains(strings.ToLower(k), "password") || strings.Contains(strings.ToLower(k), "secret") {
cleaned[k] = "***"
} else {
cleaned[k] = v
}
}
clone.Config = cleaned
}
return clone
}
func handleListModules(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
mods, err := db.listModulesAll()
if err != nil {
serverError(w, err)
return
}
out := make([]Module, 0, len(mods))
for _, m := range mods {
out = append(out, publicModule(m))
}
infra.HTTPJSONResponse(w, http.StatusOK, out)
})
}
type modulePayload struct {
Name string `json:"name"`
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
EventFilter []string `json:"event_filter"`
Config JSONValue `json:"config"`
}
func handleCreateModule(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
var body modulePayload
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if body.Name == "" || body.Kind == "" {
badRequest(w, "name and kind required")
return
}
m := &Module{
Name: body.Name, Kind: body.Kind, Enabled: body.Enabled,
EventFilter: body.EventFilter, Config: body.Config,
}
if err := db.saveModule(m); err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, publicModule(*m))
})
}
func handleUpdateModule(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
existing, err := db.getModule(id)
if err != nil {
notFound(w, "module not found")
return
}
// Partial body: preserve fields the client did not include. We rely
// on a generic map to detect omitted vs explicit-null because PATCH
// callers do not always send the full record.
var raw map[string]json.RawMessage
if err := infra.HTTPParseBody(r, &raw, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
decode := func(key string, into interface{}) {
if v, ok := raw[key]; ok {
_ = json.Unmarshal(v, into)
}
}
decode("name", &existing.Name)
decode("kind", &existing.Kind)
decode("enabled", &existing.Enabled)
if v, ok := raw["event_filter"]; ok {
_ = json.Unmarshal(v, &existing.EventFilter)
}
if v, ok := raw["config"]; ok {
var cfg JSONValue
_ = json.Unmarshal(v, &cfg)
// Re-inject masked fields the UI left as "***" so a partial
// edit does not nuke stored secrets.
merged := JSONValue{}
for k, val := range existing.Config {
merged[k] = val
}
for k, val := range cfg {
if s, isStr := val.(string); isStr && s == "***" {
continue
}
merged[k] = val
}
existing.Config = merged
}
if err := db.saveModule(existing); err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, publicModule(*existing))
})
}
func handleDeleteModule(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.deleteModule(id); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
})
}
func handleModuleLogs(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
limit := 100
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
limit = n
}
}
out, err := db.listModuleLogs(id, limit)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, out)
})
}
// handleTestModule executes the kind-specific test_connection probe with
// the *current stored config* (or with an incoming config payload, for
// pre-save validation). Returns {ok, status, error} regardless of outcome
// so the UI can show a useful message.
func handleTestModule(db *DB, dispatcher *Dispatcher) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var m *Module
if id == "draft" {
// Pre-save test path: caller supplies a full module payload.
var body modulePayload
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
m = &Module{Kind: body.Kind, Config: body.Config}
} else {
got, err := db.getModule(id)
if err != nil {
notFound(w, "module not found")
return
}
m = got
}
h, ok := dispatcher.handlers[m.Kind]
if !ok {
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
"ok": false, "status": 0, "error": "unknown kind: " + m.Kind,
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout)
defer cancel()
start := time.Now()
status, err := h.TestConnection(ctx, *m)
resp := map[string]interface{}{
"ok": err == nil,
"status": status,
"duration_ms": int(time.Since(start).Milliseconds()),
}
if err != nil {
resp["error"] = err.Error()
}
infra.HTTPJSONResponse(w, http.StatusOK, resp)
})
}
// handleCardJiraSync returns the per-card Jira sync state for the indicator
// tooltip. Reads cards.jira_last_* columns + dispatcher inflight map. The
// caller does not need admin: any authenticated user can see the state of
// their cards. Returns 200 + zero-valued state when the card has no link
// yet (so the UI can show the gray indicator without a special case).
func handleCardJiraSync(db *DB, dispatcher *Dispatcher) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if uid == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
id := r.PathValue("id")
state, err := db.readCardJiraSync(id)
if err != nil {
notFound(w, "card not found")
return
}
state.Inflight = dispatcher.IsInflight(id)
// Resolve issue URL by reading any enabled jira module's base_url. We
// pick the first match because the kanban-jira link is conceptually
// 1:1 — multiple jira modules pointing at different projects would be
// a misconfiguration.
if state.JiraKey != "" {
if mods, err := db.listModulesEnabled(); err == nil {
for _, m := range mods {
if m.Kind != "jira" {
continue
}
cfg, perr := parseJiraConfig(m)
if perr == nil && cfg.BaseURL != "" {
state.IssueURL = cfg.BaseURL + "/browse/" + state.JiraKey
break
}
}
}
}
infra.HTTPJSONResponse(w, http.StatusOK, state)
}
}
+235
View File
@@ -0,0 +1,235 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
// withModuleKey sets KANBAN_MODULE_KEY for the duration of a test and
// restores the previous value afterwards.
func withModuleKey(t *testing.T, value string) {
t.Helper()
prev := os.Getenv(moduleKeyEnv)
t.Setenv(moduleKeyEnv, value)
t.Cleanup(func() { _ = os.Setenv(moduleKeyEnv, prev) })
}
func TestCryptoRoundTrip(t *testing.T) {
withModuleKey(t, "test-passphrase")
plain := []byte(`{"hello":"world"}`)
cipherBlob, nonce, err := encryptConfig(plain)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
got, err := decryptConfig(cipherBlob, nonce)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if string(got) != string(plain) {
t.Fatalf("roundtrip mismatch: got %q want %q", got, plain)
}
}
func TestCryptoMissingKey(t *testing.T) {
t.Setenv(moduleKeyEnv, "")
if _, _, err := encryptConfig([]byte("x")); err == nil {
t.Fatal("expected error when KANBAN_MODULE_KEY unset")
}
}
func TestSaveAndLoadModule(t *testing.T) {
withModuleKey(t, "test-passphrase")
db := setupTestDB(t)
m := &Module{
Name: "jira-test", Kind: "jira", Enabled: true,
EventFilter: []string{"card.created", "card.moved"},
Config: JSONValue{
"base_url": "https://example.atlassian.net",
"email": "x@y.z",
"api_token": "secret-123",
},
}
if err := db.saveModule(m); err != nil {
t.Fatalf("save: %v", err)
}
if m.ID == "" {
t.Fatal("ID not assigned on insert")
}
got, err := db.getModule(m.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Config["api_token"] != "secret-123" {
t.Fatalf("token roundtrip failed: %v", got.Config["api_token"])
}
}
func TestFilterMatches(t *testing.T) {
if !filterMatches([]string{"card.created"}, "card.created") {
t.Fatal("exact match")
}
if !filterMatches([]string{"*"}, "anything") {
t.Fatal("wildcard")
}
if filterMatches([]string{"card.created"}, "card.moved") {
t.Fatal("non-match should be false")
}
}
func TestCardOptOutTag(t *testing.T) {
c := cardForJira{Tags: []string{"foo", "NoJira", "bar"}}
if !c.hasTag("nojira") {
t.Fatal("nojira (case-insensitive) not detected")
}
if c.hasTag("missing") {
t.Fatal("missing tag returned true")
}
}
func TestJiraHandler_TransitionMappingMissing(t *testing.T) {
withModuleKey(t, "k")
db := setupTestDB(t)
col, _ := db.CreateColumn("Backlog")
card, _ := db.CreateCard(col.ID, "req", "t", "d", "")
// Link the card so the create-fallback path is skipped.
_ = db.setCardJiraKey(card.ID, "KAN-1")
h := &jiraHandler{}
_, err := h.transition(context.Background(), db, jiraConfig{BaseURL: "http://x"}, Event{Type: "card.moved", CardID: card.ID})
if err == nil || !strings.Contains(err.Error(), "status_map") {
t.Fatalf("expected status_map error, got %v", err)
}
}
func TestJiraHandler_TestConnectionHitsMyself(t *testing.T) {
var path string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"accountId":"abc"}`)
}))
defer srv.Close()
h := &jiraHandler{}
m := Module{Kind: "jira", Config: JSONValue{
"base_url": srv.URL,
"email": "x@y.z",
"api_token": "tok",
}}
status, err := h.TestConnection(context.Background(), m)
if err != nil {
t.Fatalf("TestConnection: %v", err)
}
if status != 200 {
t.Fatalf("status = %d, want 200", status)
}
if path != "/rest/api/3/myself" {
t.Fatalf("path = %q, want /rest/api/3/myself", path)
}
}
func TestJiraHandler_CreateLinksCardKey(t *testing.T) {
withModuleKey(t, "test-passphrase")
db := setupTestDB(t)
user, _ := db.CreateUser("alice", "passw", "Alice")
col, _ := db.CreateColumn("Todo")
card, _ := db.CreateCard(col.ID, "req", "Buy bread", "desc", user.ID)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/rest/api/3/issue":
b, _ := io.ReadAll(r.Body)
var p struct {
Fields struct {
Summary string `json:"summary"`
} `json:"fields"`
}
_ = json.Unmarshal(b, &p)
if p.Fields.Summary != "Buy bread" {
t.Errorf("summary = %q", p.Fields.Summary)
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"id":"10000","key":"KAN-1"}`)
case r.Method == http.MethodGet && r.URL.Path == "/rest/api/3/issue/KAN-1/transitions":
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"transitions":[{"id":"11","name":"Start","to":{"name":"To Do"}}]}`)
case r.Method == http.MethodPost && r.URL.Path == "/rest/api/3/issue/KAN-1/transitions":
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodPut && r.URL.Path == "/rest/api/3/issue/KAN-1":
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
h := &jiraHandler{}
mod := Module{Kind: "jira", Config: JSONValue{
"base_url": srv.URL,
"email": "x@y.z",
"api_token": "tok",
"project_key": "KAN",
"status_map": map[string]interface{}{"Todo": "To Do"},
}}
status, err := h.Handle(context.Background(), db, mod, Event{Type: "card.created", CardID: card.ID})
if err != nil {
t.Fatalf("Handle: %v", err)
}
if status != http.StatusCreated {
t.Fatalf("status = %d, want 201", status)
}
again, err := db.getCardForJira(card.ID)
if err != nil {
t.Fatalf("get card: %v", err)
}
if again.JiraKey != "KAN-1" {
t.Fatalf("jira_key = %q, want KAN-1", again.JiraKey)
}
}
func TestDispatcher_Cutoff(t *testing.T) {
withModuleKey(t, "k")
db := setupTestDB(t)
col, _ := db.CreateColumn("Todo")
// Create card BEFORE the module so cutoffOK rejects it.
card, _ := db.CreateCard(col.ID, "req", "t", "d", "")
time.Sleep(20 * time.Millisecond)
mod := Module{ID: "m", CreatedAt: nowRFC3339()}
if cutoffOK(db, mod, Event{CardID: card.ID}) {
t.Fatal("card pre-dating module should be filtered out")
}
// Once linked, cutoff should allow it.
_ = db.setCardJiraKey(card.ID, "KAN-9")
if !cutoffOK(db, mod, Event{CardID: card.ID}) {
t.Fatal("linked card must pass cutoff even if older")
}
}
func TestIsAdmin(t *testing.T) {
db := setupTestDB(t)
u, _ := db.CreateUser("egutierrez", "passw", "Egu")
// Migration 015 marks egutierrez admin via UPDATE WHERE username, but
// that only takes effect when the row already exists. In production
// the migration runs against an existing user list; in tests we create
// users after migration, so simulate the same outcome explicitly.
if _, err := db.conn.Exec(`UPDATE users SET is_admin = 1 WHERE username = ?`, "egutierrez"); err != nil {
t.Fatalf("seed admin: %v", err)
}
ok, err := db.IsAdmin(u.ID)
if err != nil {
t.Fatalf("IsAdmin: %v", err)
}
if !ok {
t.Fatal("egutierrez must be admin after seed")
}
other, _ := db.CreateUser("alice", "passw", "Alice")
ok, _ = db.IsAdmin(other.ID)
if ok {
t.Fatal("alice must not be admin by default")
}
}
+328
View File
@@ -0,0 +1,328 @@
package main
import (
"database/sql"
"fmt"
"regexp"
"strings"
"time"
)
// Notification kinds, ordered by priority (highest first). When a single
// message triggers multiple kinds for one user, the highest-priority kind
// is the one persisted.
const (
NotifKindMention = "mention"
NotifKindAssignedChat = "assigned_chat"
NotifKindReply = "reply"
)
func notifKindPriority(k string) int {
switch k {
case NotifKindMention:
return 3
case NotifKindAssignedChat:
return 2
case NotifKindReply:
return 1
}
return 0
}
type Notification struct {
ID string `json:"id"`
UserID string `json:"user_id"`
CardID string `json:"card_id"`
MessageID string `json:"message_id"`
Kind string `json:"kind"`
ActorID string `json:"actor_id"`
CreatedAt string `json:"created_at"`
ReadAt *string `json:"read_at"`
CardTitle string `json:"card_title"`
CardSeqNum int `json:"card_seq_num"`
ActorName string `json:"actor_name"`
Snippet string `json:"snippet"`
}
type CardMention struct {
ID string `json:"id"`
CardID string `json:"card_id"`
MessageID string `json:"message_id"`
UserID string `json:"user_id"`
CreatedAt string `json:"created_at"`
}
var mentionRe = regexp.MustCompile(`(?i)@([a-z0-9][a-z0-9_.-]{0,63})`)
// extractMentions returns the set of @usernames referenced in body, lowercased.
// The leading '@' is not included. Each username is returned at most once.
func extractMentions(body string) []string {
matches := mentionRe.FindAllStringSubmatch(body, -1)
if len(matches) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(matches))
for _, m := range matches {
u := strings.ToLower(m[1])
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
out = append(out, u)
}
return out
}
// CreateCardMessageAndNotify wraps CreateCardMessage with mention parsing,
// notification fan-out and pub/sub publication. The returned slice contains
// the user_ids that received a notification (useful for tests).
func (db *DB) CreateCardMessageAndNotify(cardID, authorID, body string, hub *EventHub) (*CardMessage, []Notification, []CardMention, error) {
msg, err := db.CreateCardMessage(cardID, authorID, body)
if err != nil {
return nil, nil, nil, err
}
mentions, err := db.resolveAndStoreMentions(cardID, msg.ID, body)
if err != nil {
return msg, nil, nil, err
}
notifs, err := db.fanoutNotifications(cardID, msg, authorID, mentions)
if err != nil {
return msg, nil, mentions, err
}
if hub != nil {
hub.PublishJSON("message.created", cardID, "", msg)
for _, n := range notifs {
hub.PublishJSON("notification.created", cardID, n.UserID, n)
}
}
return msg, notifs, mentions, nil
}
// resolveAndStoreMentions parses @usernames from body, resolves them to
// existing user_ids (silently ignoring unknowns) and persists the matches
// in card_mentions.
func (db *DB) resolveAndStoreMentions(cardID, messageID, body string) ([]CardMention, error) {
usernames := extractMentions(body)
if len(usernames) == 0 {
return nil, nil
}
placeholders := strings.Repeat("?,", len(usernames))
placeholders = placeholders[:len(placeholders)-1]
args := make([]interface{}, 0, len(usernames))
for _, u := range usernames {
args = append(args, u)
}
rows, err := db.conn.Query(
fmt.Sprintf(`SELECT id, username FROM users WHERE username IN (%s)`, placeholders),
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
resolved := map[string]string{}
for rows.Next() {
var id, uname string
if err := rows.Scan(&id, &uname); err != nil {
return nil, err
}
resolved[uname] = id
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(resolved) == 0 {
return nil, nil
}
now := time.Now().UTC().Format(time.RFC3339)
out := make([]CardMention, 0, len(resolved))
for _, userID := range resolved {
m := CardMention{ID: newID(), CardID: cardID, MessageID: messageID, UserID: userID, CreatedAt: now}
if _, err := db.conn.Exec(
`INSERT INTO card_mentions (id, card_id, message_id, user_id, created_at) VALUES (?, ?, ?, ?, ?)`,
m.ID, m.CardID, m.MessageID, m.UserID, m.CreatedAt,
); err != nil {
return out, err
}
out = append(out, m)
}
return out, nil
}
// fanoutNotifications computes the recipient set for a new message and
// inserts one notification row per recipient with the highest-priority kind.
//
// Recipients = {assignee_id of card} {previous authors of card_messages
// on this card} {users mentioned in this message} \ {author}.
//
// Kind precedence: mention > assigned_chat > reply.
func (db *DB) fanoutNotifications(cardID string, msg *CardMessage, authorID string, mentions []CardMention) ([]Notification, error) {
recipients := map[string]string{} // userID -> kind
upgrade := func(userID, kind string) {
if userID == "" || userID == authorID {
return
}
existing, ok := recipients[userID]
if !ok || notifKindPriority(kind) > notifKindPriority(existing) {
recipients[userID] = kind
}
}
// Previous authors on this card.
rows, err := db.conn.Query(
`SELECT DISTINCT author_id FROM card_messages
WHERE card_id = ? AND author_id IS NOT NULL AND author_id != '' AND id != ?`,
cardID, msg.ID,
)
if err != nil {
return nil, err
}
for rows.Next() {
var uid sql.NullString
if err := rows.Scan(&uid); err != nil {
rows.Close()
return nil, err
}
if uid.Valid {
upgrade(uid.String, NotifKindReply)
}
}
rows.Close()
// Assignee.
var assignee sql.NullString
if err := db.conn.QueryRow(`SELECT assignee_id FROM cards WHERE id = ?`, cardID).Scan(&assignee); err != nil && err != sql.ErrNoRows {
return nil, err
}
if assignee.Valid {
upgrade(assignee.String, NotifKindAssignedChat)
}
// Mentions (highest priority).
for _, m := range mentions {
upgrade(m.UserID, NotifKindMention)
}
if len(recipients) == 0 {
return nil, nil
}
now := time.Now().UTC().Format(time.RFC3339)
out := make([]Notification, 0, len(recipients))
// Snippet for hydrated notif payload.
snippet := msg.Body
if len(snippet) > 140 {
snippet = snippet[:140] + "…"
}
var cardTitle string
var cardSeq int
_ = db.conn.QueryRow(`SELECT title, seq_num FROM cards WHERE id = ?`, cardID).Scan(&cardTitle, &cardSeq)
var actorName string
_ = db.conn.QueryRow(`SELECT COALESCE(NULLIF(display_name, ''), username) FROM users WHERE id = ?`, authorID).Scan(&actorName)
for userID, kind := range recipients {
n := Notification{
ID: newID(), UserID: userID, CardID: cardID, MessageID: msg.ID,
Kind: kind, ActorID: authorID, CreatedAt: now,
CardTitle: cardTitle, CardSeqNum: cardSeq, ActorName: actorName, Snippet: snippet,
}
if _, err := db.conn.Exec(
`INSERT INTO notifications (id, user_id, card_id, message_id, kind, actor_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
n.ID, n.UserID, n.CardID, n.MessageID, n.Kind, n.ActorID, n.CreatedAt,
); err != nil {
return out, err
}
out = append(out, n)
}
return out, nil
}
// ListNotifications returns notifications for userID. If onlyUnread is true,
// already-read entries are skipped. Limit defaults to 50 when <= 0.
func (db *DB) ListNotifications(userID string, onlyUnread bool, limit int) ([]Notification, error) {
if limit <= 0 {
limit = 50
}
q := `SELECT n.id, n.user_id, n.card_id, n.message_id, n.kind, n.actor_id, n.created_at, n.read_at,
COALESCE(c.title, ''), COALESCE(c.seq_num, 0),
COALESCE(NULLIF(u.display_name, ''), u.username, ''),
COALESCE(m.body, '')
FROM notifications n
LEFT JOIN cards c ON c.id = n.card_id
LEFT JOIN users u ON u.id = n.actor_id
LEFT JOIN card_messages m ON m.id = n.message_id
WHERE n.user_id = ?`
if onlyUnread {
q += ` AND n.read_at IS NULL`
}
q += ` ORDER BY n.created_at DESC LIMIT ?`
rows, err := db.conn.Query(q, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Notification{}
for rows.Next() {
var n Notification
var readAt sql.NullString
var body string
if err := rows.Scan(&n.ID, &n.UserID, &n.CardID, &n.MessageID, &n.Kind, &n.ActorID, &n.CreatedAt,
&readAt, &n.CardTitle, &n.CardSeqNum, &n.ActorName, &body); err != nil {
return nil, err
}
if readAt.Valid {
s := readAt.String
n.ReadAt = &s
}
if len(body) > 140 {
n.Snippet = body[:140] + "…"
} else {
n.Snippet = body
}
out = append(out, n)
}
return out, rows.Err()
}
func (db *DB) CountUnreadNotifications(userID string) (int, error) {
var n int
err := db.conn.QueryRow(
`SELECT COUNT(*) FROM notifications WHERE user_id = ? AND read_at IS NULL`, userID,
).Scan(&n)
return n, err
}
func (db *DB) MarkNotificationRead(userID, notifID string) error {
now := time.Now().UTC().Format(time.RFC3339)
res, err := db.conn.Exec(
`UPDATE notifications SET read_at = ? WHERE id = ? AND user_id = ? AND read_at IS NULL`,
now, notifID, userID,
)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
// Not an error: idempotent.
return nil
}
return nil
}
func (db *DB) MarkAllNotificationsRead(userID string) (int, error) {
now := time.Now().UTC().Format(time.RFC3339)
res, err := db.conn.Exec(
`UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL`,
now, userID,
)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"reflect"
"sort"
"testing"
)
func TestExtractMentions(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"hola @alice", []string{"alice"}},
{"@Bob y @bob mismo", []string{"bob"}},
{"sin menciones", nil},
{"email@foo.com no cuenta como @real_user", []string{"foo.com", "real_user"}},
{"@a-b-c y @d.e", []string{"a-b-c", "d.e"}},
}
for _, c := range cases {
got := extractMentions(c.in)
sort.Strings(got)
sort.Strings(c.want)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("extractMentions(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func mkUser(t *testing.T, db *DB, username string) string {
t.Helper()
u, err := db.CreateUser(username, "passw", username)
if err != nil {
t.Fatalf("CreateUser %q: %v", username, err)
}
return u.ID
}
func mkCard(t *testing.T, db *DB, columnID, requester, title, assigneeID string) string {
t.Helper()
c, err := db.CreateCard(columnID, requester, title, "", "")
if err != nil {
t.Fatalf("CreateCard: %v", err)
}
if assigneeID != "" {
if err := db.UpdateCardWithActor(c.ID, CardPatch{AssigneeID: &assigneeID, HasAssignee: true}, ""); err != nil {
t.Fatalf("assign: %v", err)
}
}
return c.ID
}
func TestCreateCardMessageAndNotify_AssigneeAndPreviousAuthors(t *testing.T) {
db := setupTestDB(t)
alice := mkUser(t, db, "alice")
bob := mkUser(t, db, "bob")
carol := mkUser(t, db, "carol")
col, err := db.CreateColumn("Todo")
if err != nil {
t.Fatalf("CreateColumn: %v", err)
}
card := mkCard(t, db, col.ID, "x", "card", bob)
// 1) alice writes; bob is assignee → bob gets assigned_chat.
_, notifs, _, err := db.CreateCardMessageAndNotify(card, alice, "hola", nil)
if err != nil {
t.Fatalf("create msg: %v", err)
}
if len(notifs) != 1 || notifs[0].UserID != bob || notifs[0].Kind != NotifKindAssignedChat {
t.Fatalf("expected single assigned_chat for bob, got %+v", notifs)
}
// 2) carol replies (carol is neither assignee nor previous author).
// alice (previous author) gets reply; bob (assignee) gets assigned_chat.
_, notifs, _, err = db.CreateCardMessageAndNotify(card, carol, "hola alice", nil)
if err != nil {
t.Fatalf("create msg: %v", err)
}
gotKinds := map[string]string{}
for _, n := range notifs {
gotKinds[n.UserID] = n.Kind
}
wantKinds := map[string]string{alice: NotifKindReply, bob: NotifKindAssignedChat}
if !reflect.DeepEqual(gotKinds, wantKinds) {
t.Fatalf("kinds = %+v, want %+v", gotKinds, wantKinds)
}
}
func TestCreateCardMessageAndNotify_MentionsBeatOtherKinds(t *testing.T) {
db := setupTestDB(t)
alice := mkUser(t, db, "alice")
bob := mkUser(t, db, "bob")
col, _ := db.CreateColumn("Todo")
card := mkCard(t, db, col.ID, "x", "card", bob) // bob is assignee
// alice mentions bob explicitly → kind must be 'mention', not 'assigned_chat'.
_, notifs, mentions, err := db.CreateCardMessageAndNotify(card, alice, "oye @bob mira esto", nil)
if err != nil {
t.Fatalf("create: %v", err)
}
if len(mentions) != 1 || mentions[0].UserID != bob {
t.Fatalf("mentions = %+v, want [bob]", mentions)
}
if len(notifs) != 1 || notifs[0].UserID != bob || notifs[0].Kind != NotifKindMention {
t.Fatalf("notifs = %+v, want single mention for bob", notifs)
}
}
func TestCreateCardMessageAndNotify_UnknownMentionsIgnored(t *testing.T) {
db := setupTestDB(t)
alice := mkUser(t, db, "alice")
col, _ := db.CreateColumn("Todo")
card := mkCard(t, db, col.ID, "x", "card", "")
_, notifs, mentions, err := db.CreateCardMessageAndNotify(card, alice, "hola @noexiste", nil)
if err != nil {
t.Fatalf("create: %v", err)
}
if len(mentions) != 0 || len(notifs) != 0 {
t.Fatalf("got mentions=%v notifs=%v, want empty", mentions, notifs)
}
}
func TestCreateCardMessageAndNotify_AuthorNeverSelfNotified(t *testing.T) {
db := setupTestDB(t)
alice := mkUser(t, db, "alice")
col, _ := db.CreateColumn("Todo")
card := mkCard(t, db, col.ID, "x", "card", alice) // alice is assignee
// alice mentions herself + is assignee → no notification.
_, notifs, _, err := db.CreateCardMessageAndNotify(card, alice, "monologo @alice", nil)
if err != nil {
t.Fatalf("create: %v", err)
}
if len(notifs) != 0 {
t.Fatalf("notifs = %+v, want empty (self)", notifs)
}
}
func TestListAndMarkRead(t *testing.T) {
db := setupTestDB(t)
alice := mkUser(t, db, "alice")
bob := mkUser(t, db, "bob")
col, _ := db.CreateColumn("Todo")
card := mkCard(t, db, col.ID, "x", "card", bob)
_, _, _, _ = db.CreateCardMessageAndNotify(card, alice, "1", nil)
_, _, _, _ = db.CreateCardMessageAndNotify(card, alice, "2", nil)
got, err := db.ListNotifications(bob, true, 0)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if n, _ := db.CountUnreadNotifications(bob); n != 2 {
t.Fatalf("unread count = %d, want 2", n)
}
if err := db.MarkNotificationRead(bob, got[0].ID); err != nil {
t.Fatalf("mark read: %v", err)
}
if n, _ := db.CountUnreadNotifications(bob); n != 1 {
t.Fatalf("unread count after mark = %d, want 1", n)
}
// idempotent
if err := db.MarkNotificationRead(bob, got[0].ID); err != nil {
t.Fatalf("mark read 2nd time: %v", err)
}
if n, _ := db.MarkAllNotificationsRead(bob); n != 1 {
t.Fatalf("mark all = %d, want 1", n)
}
if n, _ := db.CountUnreadNotifications(bob); n != 0 {
t.Fatalf("unread count after mark-all = %d, want 0", n)
}
}
+297
View File
@@ -0,0 +1,297 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"fn-registry/functions/infra"
"nhooyr.io/websocket"
)
const (
sseHeartbeat = 25 * time.Second
wsChatHeartbeat = 30 * time.Second
wsChatReadLimit = 64 * 1024
wsChatWriteWait = 5 * time.Second
typingDebounceMs = 1500
)
// handleEventStream serves the per-user SSE channel.
//
// One stream per browser tab. Auto-reconnect lives on the client (browsers
// retry EventSource by default). The server publishes:
//
// board.* — column/card mutations (broadcast to every user).
// message.created — chat message added on any card (broadcast).
// notification.* — private events for one recipient (UserID set).
func handleEventStream(hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if userID == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
// Initial flush so the browser knows the stream is open.
fmt.Fprint(w, ": hello\n\n")
flusher.Flush()
ch := hub.SubscribeUser(userID)
defer hub.UnsubscribeUser(userID, ch)
ticker := time.NewTicker(sseHeartbeat)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
case ev, ok := <-ch:
if !ok {
return
}
if ev.UserID != "" && ev.UserID != userID {
// Defensive: hub already routes private events but the
// broadcast path could leak if a future change adds
// fan-out. Skip explicitly.
continue
}
b, err := json.Marshal(ev)
if err != nil {
continue
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, b); err != nil {
return
}
flusher.Flush()
}
}
}
}
// cardChatWSIn is the message sent by the browser over the per-card WS.
type cardChatWSIn struct {
Type string `json:"type"` // "send" | "typing"
Body string `json:"body,omitempty"` // only for "send"
}
// cardChatWSOut is the message the server pushes to subscribers of a card.
//
// Types:
//
// message.created — new CardMessage (full payload).
// typing — UserID is typing (no body).
// error — server-side error, connection stays open.
type cardChatWSOut struct {
Type string `json:"type"`
Message *CardMessage `json:"message,omitempty"`
UserID string `json:"user_id,omitempty"`
Error string `json:"error,omitempty"`
}
// handleCardChatWS upgrades the request to WebSocket and provides bidirectional
// realtime chat for a single card. Each connection is subscribed to the
// card's event channel; sends originating from this connection are persisted
// then republished through the hub so peer connections (including this one)
// see them.
func handleCardChatWS(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cardID := r.PathValue("id")
userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if userID == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
// Confirm card exists before upgrading to avoid leaking goroutines on
// invalid IDs.
var exists int
if err := db.conn.QueryRow(`SELECT 1 FROM cards WHERE id = ?`, cardID).Scan(&exists); err != nil {
notFound(w, "card not found")
return
}
conn, err := infra.WSUpgrader(w, r, []string{"*"})
if err != nil {
return
}
defer conn.Close(websocket.StatusInternalError, "internal")
conn.SetReadLimit(wsChatReadLimit)
ch := hub.SubscribeCard(cardID)
defer hub.UnsubscribeCard(cardID, ch)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// Writer goroutine: forward hub events to this socket.
go func() {
ticker := time.NewTicker(wsChatHeartbeat)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
wctx, c := context.WithTimeout(ctx, wsChatWriteWait)
_ = conn.Ping(wctx)
c()
case ev, ok := <-ch:
if !ok {
return
}
if ev.CardID != cardID {
continue
}
out := cardChatWSOut{Type: ev.Type}
switch ev.Type {
case "message.created":
var m CardMessage
if err := json.Unmarshal(ev.Payload, &m); err == nil {
out.Message = &m
}
case "card.typing":
var p struct {
UserID string `json:"user_id"`
}
_ = json.Unmarshal(ev.Payload, &p)
// Skip echoing the typer's own indicator.
if p.UserID == userID {
continue
}
out.UserID = p.UserID
default:
continue
}
b, err := json.Marshal(out)
if err != nil {
continue
}
wctx, c := context.WithTimeout(ctx, wsChatWriteWait)
if err := conn.Write(wctx, websocket.MessageText, b); err != nil {
c()
cancel()
return
}
c()
}
}
}()
// Reader loop: persist sends and broadcast typing.
for {
_, raw, err := conn.Read(ctx)
if err != nil {
return
}
var in cardChatWSIn
if err := json.Unmarshal(raw, &in); err != nil {
continue
}
switch in.Type {
case "send":
if in.Body == "" {
continue
}
if _, _, _, err := db.CreateCardMessageAndNotify(cardID, userID, in.Body, hub); err != nil {
b, _ := json.Marshal(cardChatWSOut{Type: "error", Error: err.Error()})
wctx, c := context.WithTimeout(ctx, wsChatWriteWait)
_ = conn.Write(wctx, websocket.MessageText, b)
c()
}
case "typing":
hub.PublishJSON("card.typing", cardID, "", map[string]string{"user_id": userID})
}
}
}
}
// Notification HTTP handlers.
func handleListNotifications(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if userID == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
onlyUnread := r.URL.Query().Get("unread") == "1"
limit := 50
out, err := db.ListNotifications(userID, onlyUnread, limit)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, out)
}
}
func handleUnreadCount(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if userID == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
n, err := db.CountUnreadNotifications(userID)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]int{"count": n})
}
}
func handleMarkNotificationRead(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if userID == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
id := r.PathValue("id")
if err := db.MarkNotificationRead(userID, id); err != nil {
serverError(w, err)
return
}
if hub != nil {
hub.PublishJSON("notification.read", "", userID, map[string]string{"id": id})
}
w.WriteHeader(http.StatusNoContent)
}
}
func handleMarkAllNotificationsRead(db *DB, hub *EventHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if userID == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
n, err := db.MarkAllNotificationsRead(userID)
if err != nil {
serverError(w, err)
return
}
if hub != nil {
hub.PublishJSON("notification.read_all", "", userID, map[string]int{"count": n})
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]int{"count": n})
}
}
+588
View File
@@ -0,0 +1,588 @@
package main
import (
"database/sql"
"fmt"
"sort"
"time"
)
// DailyReport — agregaciones por dia natural (TZ del servidor a menos que el
// caller pase una TZ explicita). Issue 0093.
type DailyReport struct {
Date string `json:"date"`
TZ string `json:"tz"`
StartTs string `json:"start_ts"`
EndTs string `json:"end_ts"`
KPIs DailyKPIs `json:"kpis"`
TopAssigneesDone []UserCount `json:"top_assignees_done"`
TopAssigneesCreated []UserCount `json:"top_assignees_created"`
TopRequestersAdded []NamedCount `json:"top_requesters_added"`
TopRequestersDone []NamedCount `json:"top_requesters_done"`
DoneCards []DoneCard `json:"done_cards"`
ReopenedCards []ReopenedEntry `json:"reopened_cards"`
StaleCards StaleBuckets `json:"stale_cards"`
LeadTime LeadTimeStats `json:"lead_time"`
HourlyMoves [24]int `json:"hourly_moves"`
Deadlines DeadlineSummary `json:"deadlines"`
TagsDone []NamedCount `json:"tags_done"`
ArchivedToday int `json:"archived_today"`
}
type DailyKPIs struct {
Done int `json:"done"`
Created int `json:"created"`
Moves int `json:"moves"`
BlockedMs int64 `json:"blocked_ms"`
DeadlinesMet int `json:"deadlines_met"`
DeadlinesMissed int `json:"deadlines_missed"`
Reopened int `json:"reopened"`
ArchivedAuto int `json:"archived_auto"`
ArchivedManual int `json:"archived_manual"`
}
type UserCount struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Count int `json:"count"`
}
type NamedCount struct {
Name string `json:"name"`
Count int `json:"count"`
}
type DoneCard struct {
ID string `json:"id"`
SeqNum int `json:"seq_num"`
Title string `json:"title"`
Requester string `json:"requester"`
AssigneeID *string `json:"assignee_id"`
AssigneeName *string `json:"assignee_name"`
Tags []string `json:"tags"`
ColumnID string `json:"column_id"`
ColumnName string `json:"column_name"`
CompletedAt string `json:"completed_at"`
CreatedAt string `json:"created_at"`
LeadTimeMs int64 `json:"lead_time_ms"`
Color string `json:"color"`
}
type ReopenedEntry struct {
CardID string `json:"card_id"`
Title string `json:"title"`
SeqNum int `json:"seq_num"`
FromColumn string `json:"from_column"`
ToColumn string `json:"to_column"`
Ts string `json:"ts"`
ActorID *string `json:"actor_id"`
ActorName *string `json:"actor_name"`
}
type StaleEntry struct {
CardID string `json:"card_id"`
Title string `json:"title"`
SeqNum int `json:"seq_num"`
ColumnID string `json:"column_id"`
ColumnName string `json:"column_name"`
EnteredAt string `json:"entered_at"`
Days int `json:"days"`
}
type StaleBuckets struct {
D7 []StaleEntry `json:"d7"`
D14 []StaleEntry `json:"d14"`
D30 []StaleEntry `json:"d30"`
}
type LeadTimeStats struct {
AvgMs int64 `json:"avg_ms"`
P50Ms int64 `json:"p50_ms"`
P95Ms int64 `json:"p95_ms"`
Samples int `json:"samples"`
}
type DeadlineSummary struct {
Met int `json:"met"`
Missed int `json:"missed"`
List []DeadlineMissEntry `json:"list"`
}
type DeadlineMissEntry struct {
CardID string `json:"card_id"`
Title string `json:"title"`
SeqNum int `json:"seq_num"`
Deadline string `json:"deadline"`
CompletedAt string `json:"completed_at"`
LateMs int64 `json:"late_ms"`
}
// DailyReportFor computes the report for the local day specified by date+tz.
func (db *DB) DailyReportFor(date, tz string) (*DailyReport, error) {
loc, err := time.LoadLocation(tz)
if err != nil {
loc = time.UTC
tz = "UTC"
}
t, err := time.ParseInLocation("2006-01-02", date, loc)
if err != nil {
return nil, fmt.Errorf("invalid date %q: %w", date, err)
}
start := t
end := t.Add(24 * time.Hour)
startUTC := start.UTC().Format(time.RFC3339Nano)
endUTC := end.UTC().Format(time.RFC3339Nano)
r := &DailyReport{
Date: date,
TZ: tz,
StartTs: startUTC,
EndTs: endUTC,
StaleCards: StaleBuckets{
D7: []StaleEntry{},
D14: []StaleEntry{},
D30: []StaleEntry{},
},
Deadlines: DeadlineSummary{List: []DeadlineMissEntry{}},
DoneCards: []DoneCard{},
ReopenedCards: []ReopenedEntry{},
TopAssigneesDone: []UserCount{},
TopAssigneesCreated: []UserCount{},
TopRequestersAdded: []NamedCount{},
TopRequestersDone: []NamedCount{},
TagsDone: []NamedCount{},
}
users, err := db.userNameMap()
if err != nil {
return nil, err
}
doneColIDs, doneColNames, err := db.doneColumnIDs()
if err != nil {
return nil, err
}
allColNames, err := db.allColumnNames()
if err != nil {
return nil, err
}
// --- Done cards ----------------------------------------------------------
rows, err := db.conn.Query(`
SELECT c.id, c.seq_num, c.title, c.requester, c.assignee_id, c.tags, c.column_id, c.completed_at, c.created_at, c.color, c.deadline
FROM cards c
WHERE c.completed_at IS NOT NULL
AND c.completed_at >= ? AND c.completed_at < ?
AND c.deleted_at IS NULL
ORDER BY c.completed_at DESC
`, startUTC, endUTC)
if err != nil {
return nil, err
}
leadSamples := []int64{}
assigneeDoneCount := map[string]int{}
requesterDoneCount := map[string]int{}
tagCount := map[string]int{}
for rows.Next() {
var c DoneCard
var assignee, deadline sql.NullString
var tagsJSON string
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Title, &c.Requester, &assignee, &tagsJSON, &c.ColumnID, &c.CompletedAt, &c.CreatedAt, &c.Color, &deadline); err != nil {
rows.Close()
return nil, err
}
c.Tags = parseTags(tagsJSON)
if assignee.Valid && assignee.String != "" {
a := assignee.String
c.AssigneeID = &a
if n, ok := users[a]; ok {
nm := n
c.AssigneeName = &nm
}
assigneeDoneCount[a]++
}
if c.Requester != "" {
requesterDoneCount[c.Requester]++
}
for _, tag := range c.Tags {
tagCount[tag]++
}
c.ColumnName = allColNames[c.ColumnID]
// Lead time created -> completed.
if ct, err := time.Parse(time.RFC3339Nano, c.CreatedAt); err == nil {
if compt, err := time.Parse(time.RFC3339Nano, c.CompletedAt); err == nil {
c.LeadTimeMs = compt.Sub(ct).Milliseconds()
if c.LeadTimeMs >= 0 {
leadSamples = append(leadSamples, c.LeadTimeMs)
}
}
}
// Deadlines.
if deadline.Valid && deadline.String != "" {
if dlt, err := time.Parse(time.RFC3339Nano, deadline.String); err == nil {
if compt, err := time.Parse(time.RFC3339Nano, c.CompletedAt); err == nil {
if compt.After(dlt) {
r.Deadlines.Missed++
r.Deadlines.List = append(r.Deadlines.List, DeadlineMissEntry{
CardID: c.ID, Title: c.Title, SeqNum: c.SeqNum,
Deadline: deadline.String, CompletedAt: c.CompletedAt,
LateMs: compt.Sub(dlt).Milliseconds(),
})
} else {
r.Deadlines.Met++
}
}
}
}
r.DoneCards = append(r.DoneCards, c)
}
rows.Close()
r.KPIs.Done = len(r.DoneCards)
r.LeadTime = computeLeadTime(leadSamples)
r.TopAssigneesDone = topUsersFromCount(assigneeDoneCount, users, 5)
r.TopRequestersDone = topNamedFromCount(requesterDoneCount, 5)
r.TagsDone = topNamedFromCount(tagCount, 10)
_ = doneColIDs
_ = doneColNames
// --- Created (card_events kind=created) ----------------------------------
rows, err = db.conn.Query(`
SELECT e.card_id, e.actor_id, COALESCE(c.requester, '')
FROM card_events e
LEFT JOIN cards c ON c.id = e.card_id
WHERE e.kind = 'created'
AND e.created_at >= ? AND e.created_at < ?
`, startUTC, endUTC)
if err != nil {
return nil, err
}
assigneeCreatedCount := map[string]int{}
requesterAddedCount := map[string]int{}
createdN := 0
for rows.Next() {
var cardID string
var actor sql.NullString
var requester string
if err := rows.Scan(&cardID, &actor, &requester); err != nil {
rows.Close()
return nil, err
}
createdN++
if actor.Valid && actor.String != "" {
assigneeCreatedCount[actor.String]++
}
if requester != "" {
requesterAddedCount[requester]++
}
}
rows.Close()
r.KPIs.Created = createdN
r.TopAssigneesCreated = topUsersFromCount(assigneeCreatedCount, users, 5)
r.TopRequestersAdded = topNamedFromCount(requesterAddedCount, 5)
// --- Moves del dia + hourly + reopened -----------------------------------
// Reopened = card que el dia X entro a una columna NO done HABIENDO estado
// en una done previa. Detectable comparando entered_at del dia con la
// entrada previa (mismo card_id).
rows, err = db.conn.Query(`
SELECT h.card_id, h.column_id, h.entered_at, h.actor_id, c.title, c.seq_num
FROM card_column_history h
JOIN cards c ON c.id = h.card_id
WHERE h.entered_at >= ? AND h.entered_at < ?
AND c.deleted_at IS NULL
ORDER BY h.entered_at ASC
`, startUTC, endUTC)
if err != nil {
return nil, err
}
hourly := [24]int{}
type moveRow struct {
cardID, columnID, enteredAt, title string
actor sql.NullString
seqNum int
}
var moves []moveRow
for rows.Next() {
var m moveRow
if err := rows.Scan(&m.cardID, &m.columnID, &m.enteredAt, &m.actor, &m.title, &m.seqNum); err != nil {
rows.Close()
return nil, err
}
moves = append(moves, m)
if ts, err := time.Parse(time.RFC3339Nano, m.enteredAt); err == nil {
h := ts.In(loc).Hour()
if h >= 0 && h < 24 {
hourly[h]++
}
}
}
rows.Close()
r.HourlyMoves = hourly
r.KPIs.Moves = len(moves)
for _, m := range moves {
// Solo interesa si la columna actual NO es done.
isDone := doneColIDs[m.columnID]
if isDone {
continue
}
// Hubo entrada previa en una columna done?
prevWasDone, prevColID := db.previousColumnWasDone(m.cardID, m.enteredAt, doneColIDs)
if prevWasDone {
entry := ReopenedEntry{
CardID: m.cardID,
Title: m.title,
SeqNum: m.seqNum,
FromColumn: allColNames[prevColID],
ToColumn: allColNames[m.columnID],
Ts: m.enteredAt,
}
if m.actor.Valid && m.actor.String != "" {
a := m.actor.String
entry.ActorID = &a
if n, ok := users[a]; ok {
nm := n
entry.ActorName = &nm
}
}
r.ReopenedCards = append(r.ReopenedCards, entry)
}
}
r.KPIs.Reopened = len(r.ReopenedCards)
// --- Stale buckets (cards activas hoy con N dias en misma columna) -------
r.StaleCards = db.staleBucketsAt(end, doneColIDs, allColNames)
// --- Bloqueado ms (lock_history que solapa con el dia) -------------------
r.KPIs.BlockedMs = db.blockedMsInRange(startUTC, endUTC)
// --- Archivadas hoy ------------------------------------------------------
var autoN, manualN int
if err := db.conn.QueryRow(`
SELECT COUNT(*) FROM cards
WHERE archived_at IS NOT NULL
AND archived_at >= ? AND archived_at < ?
AND deleted_at IS NULL
`, startUTC, endUTC).Scan(&autoN); err == nil {
// Heuristica: auto vs manual no se diferencia (no log explicito). Si
// la columna actual es is_done, asumimos auto. Mejor que nada.
_ = manualN
r.KPIs.ArchivedAuto = autoN
r.ArchivedToday = autoN
}
r.KPIs.DeadlinesMet = r.Deadlines.Met
r.KPIs.DeadlinesMissed = r.Deadlines.Missed
return r, nil
}
func (db *DB) userNameMap() (map[string]string, error) {
rows, err := db.conn.Query(`SELECT id, COALESCE(display_name,''), username FROM users`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var id, dn, un string
if err := rows.Scan(&id, &dn, &un); err != nil {
return nil, err
}
if dn != "" {
out[id] = dn
} else {
out[id] = un
}
}
return out, nil
}
func (db *DB) doneColumnIDs() (map[string]bool, map[string]string, error) {
rows, err := db.conn.Query(`SELECT id, name FROM columns WHERE is_done=1`)
if err != nil {
return nil, nil, err
}
defer rows.Close()
ids := map[string]bool{}
names := map[string]string{}
for rows.Next() {
var id, n string
if err := rows.Scan(&id, &n); err != nil {
return nil, nil, err
}
ids[id] = true
names[id] = n
}
return ids, names, nil
}
func (db *DB) allColumnNames() (map[string]string, error) {
rows, err := db.conn.Query(`SELECT id, name FROM columns`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var id, n string
if err := rows.Scan(&id, &n); err != nil {
return nil, err
}
out[id] = n
}
return out, nil
}
// previousColumnWasDone returns whether the entry of `cardID` immediately
// before `enteredAt` was in a done column.
func (db *DB) previousColumnWasDone(cardID, enteredAt string, doneColIDs map[string]bool) (bool, string) {
var colID string
err := db.conn.QueryRow(`
SELECT column_id FROM card_column_history
WHERE card_id=? AND entered_at < ?
ORDER BY entered_at DESC
LIMIT 1
`, cardID, enteredAt).Scan(&colID)
if err != nil {
return false, ""
}
return doneColIDs[colID], colID
}
func (db *DB) staleBucketsAt(asOf time.Time, doneColIDs map[string]bool, colNames map[string]string) StaleBuckets {
out := StaleBuckets{D7: []StaleEntry{}, D14: []StaleEntry{}, D30: []StaleEntry{}}
rows, err := db.conn.Query(`
SELECT h.card_id, c.title, c.seq_num, h.column_id, h.entered_at
FROM card_column_history h
JOIN cards c ON c.id = h.card_id
WHERE h.exited_at IS NULL
AND c.deleted_at IS NULL
AND c.archived_at IS NULL
`)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var s StaleEntry
if err := rows.Scan(&s.CardID, &s.Title, &s.SeqNum, &s.ColumnID, &s.EnteredAt); err != nil {
continue
}
// Skip done columns (esos se auto-archivan; no son "estancados" activos).
if doneColIDs[s.ColumnID] {
continue
}
entered, err := time.Parse(time.RFC3339Nano, s.EnteredAt)
if err != nil {
continue
}
days := int(asOf.Sub(entered).Hours() / 24)
if days < 7 {
continue
}
s.Days = days
s.ColumnName = colNames[s.ColumnID]
switch {
case days >= 30:
out.D30 = append(out.D30, s)
case days >= 14:
out.D14 = append(out.D14, s)
default:
out.D7 = append(out.D7, s)
}
}
sort.Slice(out.D7, func(i, j int) bool { return out.D7[i].Days > out.D7[j].Days })
sort.Slice(out.D14, func(i, j int) bool { return out.D14[i].Days > out.D14[j].Days })
sort.Slice(out.D30, func(i, j int) bool { return out.D30[i].Days > out.D30[j].Days })
return out
}
func (db *DB) blockedMsInRange(startUTC, endUTC string) int64 {
// Para cada periodo de lock, contar la interseccion con [start,end].
rows, err := db.conn.Query(`
SELECT locked_at, COALESCE(unlocked_at, ?) FROM card_lock_history
WHERE locked_at < ? AND COALESCE(unlocked_at, ?) > ?
`, endUTC, endUTC, endUTC, startUTC)
if err != nil {
return 0
}
defer rows.Close()
start, _ := time.Parse(time.RFC3339Nano, startUTC)
end, _ := time.Parse(time.RFC3339Nano, endUTC)
var total time.Duration
for rows.Next() {
var lstr, ustr string
if err := rows.Scan(&lstr, &ustr); err != nil {
continue
}
l, err := time.Parse(time.RFC3339Nano, lstr)
if err != nil {
continue
}
u, err := time.Parse(time.RFC3339Nano, ustr)
if err != nil {
continue
}
if l.Before(start) {
l = start
}
if u.After(end) {
u = end
}
if u.After(l) {
total += u.Sub(l)
}
}
return total.Milliseconds()
}
func topUsersFromCount(m map[string]int, names map[string]string, k int) []UserCount {
out := make([]UserCount, 0, len(m))
for id, n := range m {
out = append(out, UserCount{UserID: id, Name: names[id], Count: n})
}
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
if len(out) > k {
out = out[:k]
}
return out
}
func topNamedFromCount(m map[string]int, k int) []NamedCount {
out := make([]NamedCount, 0, len(m))
for n, c := range m {
out = append(out, NamedCount{Name: n, Count: c})
}
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
if len(out) > k {
out = out[:k]
}
return out
}
func computeLeadTime(samples []int64) LeadTimeStats {
if len(samples) == 0 {
return LeadTimeStats{}
}
sorted := make([]int64, len(samples))
copy(sorted, samples)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
var sum int64
for _, v := range sorted {
sum += v
}
p := func(q float64) int64 {
if len(sorted) == 0 {
return 0
}
idx := int(float64(len(sorted)-1) * q)
return sorted[idx]
}
return LeadTimeStats{
AvgMs: sum / int64(len(sorted)),
P50Ms: p(0.5),
P95Ms: p(0.95),
Samples: len(sorted),
}
}
+192
View File
@@ -0,0 +1,192 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// runResyncJiraFields patches every Jira issue currently linked to a kanban
// card so its issuetype / assignee / labels reflect the *latest* module
// configuration. Use cases:
//
// - We changed issue_type in the module (e.g. "Tarea Técnica" → "Epic") and
// need the backfilled issues to match.
// - We added/changed the assignee_map and want existing issues to pick up
// the mapping retroactively.
// - We renamed kanban columns and need labels re-applied.
//
// The CLI is idempotent: running it twice on the same set is a no-op for
// fields that already match. Batching mirrors `backfill-jira` so we stay
// under Jira's REST quota.
func runResyncJiraFields(args []string) error {
fs := flag.NewFlagSet("kanban resync-jira-fields", flag.ContinueOnError)
dbPath := fs.String("db", "operations.db", "SQLite database path")
batchSize := fs.Int("batch-size", 10, "Issues per batch before pausing")
pauseSec := fs.Int("pause-sec", 5, "Seconds to sleep between batches")
limit := fs.Int("limit", 0, "Maximum issues to patch (0 = no limit)")
doIssueType := fs.Bool("set-issuetype", true, "Set issuetype to module.issue_type")
doAssignee := fs.Bool("set-assignee", true, "Set assignee from module.assignee_map (or clear when no mapping)")
doLabels := fs.Bool("set-labels", false, "Re-apply labels from module.labels_map (off by default; labels were already correct after backfill)")
dryRun := fs.Bool("dry-run", false, "Print the planned PATCH for each issue and exit")
if err := fs.Parse(args); err != nil {
return err
}
db, err := openDB(*dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
_, cfg, err := activeJiraModule(db)
if err != nil {
return fmt.Errorf("module config: %w", err)
}
cards, err := listLinkedJiraCards(db, *limit)
if err != nil {
return err
}
if len(cards) == 0 {
fmt.Println("no linked cards to resync")
return nil
}
fmt.Printf("resync plan: %d issues; batch=%d pause=%ds dry_run=%v\n",
len(cards), *batchSize, *pauseSec, *dryRun)
fmt.Printf("ops: issuetype=%v(%q) assignee=%v(%d mappings) labels=%v\n",
*doIssueType, cfg.IssueType, *doAssignee, len(cfg.AssigneeMap), *doLabels)
fmt.Println()
var ok, failed, noop int
for i, c := range cards {
if i > 0 && i%*batchSize == 0 {
fmt.Printf("--- batch boundary (%d/%d) — sleeping %ds ---\n", i, len(cards), *pauseSec)
time.Sleep(time.Duration(*pauseSec) * time.Second)
}
fields := map[string]interface{}{}
if *doIssueType && cfg.IssueType != "" {
fields["issuetype"] = map[string]string{"name": cfg.IssueType}
}
if *doAssignee {
acct := cfg.AssigneeMap[c.AssigneeID]
if acct != "" {
fields["assignee"] = map[string]string{"accountId": acct}
}
// We intentionally do NOT clear the assignee when the card has no
// mapping — that would overwrite a manual Jira assignment with
// nothing. To explicitly clear, the operator can remove the card's
// kanban assignee and trigger a card.updated event.
}
if *doLabels {
labels := cfg.LabelsMap[c.ColumnName]
if labels == nil {
labels = []string{}
}
fields["labels"] = labels
}
if len(fields) == 0 {
noop++
fmt.Printf("[%4d/%4d] NOOP %s (no fields to patch)\n", i+1, len(cards), c.JiraKey)
continue
}
if *dryRun {
b, _ := json.Marshal(fields)
fmt.Printf("[%4d/%4d] PLAN %s fields=%s\n", i+1, len(cards), c.JiraKey, b)
continue
}
status, err := jiraPUTFields(context.Background(), cfg, c.JiraKey, fields)
if err != nil {
failed++
fmt.Printf("[%4d/%4d] FAIL %s http=%d err=%s\n",
i+1, len(cards), c.JiraKey, status, truncateInline(err.Error(), 100))
continue
}
ok++
fmt.Printf("[%4d/%4d] OK %s\n", i+1, len(cards), c.JiraKey)
}
fmt.Println()
fmt.Printf("done: %d ok · %d noop · %d failed · %d total\n", ok, noop, failed, len(cards))
if failed > 0 {
return fmt.Errorf("%d issues failed to patch", failed)
}
return nil
}
// linkedJiraCard is the projection used by the resync CLI. We also pull
// assignee_id so the assignee_map lookup works without re-fetching cards.
type linkedJiraCard struct {
ID string
JiraKey string
ColumnName string
AssigneeID string
}
func listLinkedJiraCards(db *DB, limit int) ([]linkedJiraCard, error) {
q := `
SELECT c.id, c.jira_key, col.name, COALESCE(c.assignee_id, '')
FROM cards c
JOIN columns col ON col.id = c.column_id
WHERE c.jira_key != ''
AND c.deleted_at IS NULL
ORDER BY c.jira_key ASC
`
args := []interface{}{}
if limit > 0 {
q += " LIMIT ? "
args = append(args, limit)
}
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("list linked cards: %w", err)
}
defer rows.Close()
out := []linkedJiraCard{}
for rows.Next() {
var c linkedJiraCard
if err := rows.Scan(&c.ID, &c.JiraKey, &c.ColumnName, &c.AssigneeID); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// jiraPUTFields is a thin wrapper around PUT /rest/api/3/issue/{key} that
// returns the HTTP status code + error. We do not need the response body —
// Jira returns 204 No Content on success.
func jiraPUTFields(ctx context.Context, c jiraConfig, key string, fields map[string]interface{}) (int, error) {
body := map[string]interface{}{"fields": fields}
raw, err := json.Marshal(body)
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
c.BaseURL+"/rest/api/3/issue/"+key, bytes.NewReader(raw))
if err != nil {
return 0, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
basic := base64.StdEncoding.EncodeToString([]byte(c.Email + ":" + c.APIToken))
req.Header.Set("Authorization", "Basic "+basic)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return resp.StatusCode, fmt.Errorf("jira PUT %s: %d %s",
key, resp.StatusCode, truncateInline(strings.TrimSpace(string(respBody)), 240))
}
return resp.StatusCode, nil
}
+166
View File
@@ -0,0 +1,166 @@
package main
import (
"flag"
"fmt"
"os/exec"
"strings"
)
// runSeedJiraData provisions (or updates) the Jira module that pushes kanban
// changes to soporte-anjana.atlassian.net, project DATA, board 33.
//
// Credentials are read from `pass` so they never appear in argv or env. The
// API token, email, and domain are loaded from the canonical entries:
//
// pass jira/anjana/api-token
// pass jira/anjana/email
// pass jira/anjana/domain
//
// Defaults can be overridden with flags (project, board, name, filter).
//
// Idempotent: if a module with the same name already exists, its config is
// rewritten (encrypted at rest by saveModule). The kanban module key
// (KANBAN_MODULE_KEY env var) must be set — the same value the running server
// uses, otherwise the server cannot decrypt the secrets we wrote.
func runSeedJiraData(args []string) error {
fs := flag.NewFlagSet("kanban seed-jira-data", flag.ContinueOnError)
dbPath := fs.String("db", "operations.db", "SQLite database path")
name := fs.String("name", "Jira DATA", "Module display name (also used as upsert key)")
project := fs.String("project", "DATA", "Jira project key (e.g. DATA)")
board := fs.Int("board", 33, "Jira board id (Agile board; informational + validated at /test)")
filter := fs.String("event-filter", "card.created,card.updated,card.moved,message.created",
"Comma-separated event types this module subscribes to")
enabled := fs.Bool("enabled", true, "Start with module enabled (true) or disabled (false)")
passEntry := fs.String("pass-prefix", "jira/anjana", "pass entry prefix; reads ${prefix}/{email,api-token,domain}")
requesterField := fs.String("requester-field", "customfield_10158",
"Jira custom field id for the required 'Área Solicitante' select (empty to disable)")
requesterDefault := fs.String("requester-default", "Transformación",
"Default 'Área Solicitante' option value for auto-created cards whose requester is not mapped")
if err := fs.Parse(args); err != nil {
return err
}
email, err := passShow(*passEntry + "/email")
if err != nil {
return fmt.Errorf("read email from pass: %w", err)
}
token, err := passShow(*passEntry + "/api-token")
if err != nil {
return fmt.Errorf("read api-token from pass: %w", err)
}
domain, err := passShow(*passEntry + "/domain")
if err != nil {
return fmt.Errorf("read domain from pass: %w", err)
}
baseURL := "https://" + strings.TrimSpace(domain)
db, err := openDB(*dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
// Default mapping for our setup: Kanban columns → Jira `Epicas en Data` board (33)
// statuses. Operator can edit via the Modulos UI once the row exists.
statusMap := map[string]string{
"HACIENDO 🚧": "In Progress",
"PNDNT FEEDBACK ▶️": "IMPLEMENTADO",
"HECHO ✅": "Done",
"IDEAS 💡": "CREADO",
"DEUDA TÉCNICA 🔄": "To Do",
"Bloqueadas": "In Progress",
}
labelsMap := map[string][]string{
"Bloqueadas": {"blocked"},
}
// kanban user_id -> Jira accountId. Resolved via Jira /user/search; the
// three current data-team users keep stable IDs across sessions. New
// users added to the kanban must be added here (or the seed re-run with
// --pass-prefix overrides) so the dispatcher can route the assignee.
assigneeMap := map[string]string{
"6a75edc6e99d8405": "712020:2cf3b82f-47d6-4597-b0e9-ffaaf3a07cc3", // Enmaa -> Enmanuel Gutierrez Perez
"039c97acf1869393": "712020:3f3ca9e1-c86e-445e-979a-bc7b82a4f45d", // alfon -> Alfonso Massaguer Gómez
"9e91db261084d529": "712020:feb5f7c5-7643-4381-977c-d83c95ba4955", // Nat -> Natalia Tajuelo Gomez
}
cfg := JSONValue{
"base_url": baseURL,
"email": email,
"api_token": token,
"project_key": *project,
"board_id": *board,
"issue_type": "Epic",
"status_map": statusMap,
"labels_map": labelsMap,
"assignee_map": assigneeMap,
}
if *requesterField != "" {
cfg["requester_field"] = *requesterField
cfg["requester_default"] = *requesterDefault
}
// Upsert by name. Module name is the human-friendly identifier; we treat
// it as unique for the purposes of seeding so re-running this command does
// not duplicate the row.
mods, err := db.listModulesAll()
if err != nil {
return fmt.Errorf("list modules: %w", err)
}
var existing *Module
for i := range mods {
if mods[i].Name == *name {
existing = &mods[i]
break
}
}
if existing != nil {
existing.Kind = "jira"
existing.Enabled = *enabled
existing.EventFilter = splitCSV(*filter)
// Merge so keys the operator added via the UI (e.g. a custom
// requester_map) survive a re-seed. Seed-managed keys are refreshed.
if existing.Config == nil {
existing.Config = JSONValue{}
}
for k, v := range cfg {
existing.Config[k] = v
}
if err := db.saveModule(existing); err != nil {
return fmt.Errorf("update module: %w", err)
}
fmt.Printf("updated module %q (id=%s)\n", existing.Name, existing.ID)
return nil
}
m := &Module{
Name: *name,
Kind: "jira",
Enabled: *enabled,
EventFilter: splitCSV(*filter),
Config: cfg,
}
if err := db.saveModule(m); err != nil {
return fmt.Errorf("create module: %w", err)
}
fmt.Printf("created module %q (id=%s)\n", m.Name, m.ID)
fmt.Printf("project: %s board: %d base_url: %s email: %s\n",
*project, *board, baseURL, email)
fmt.Println("\nnext steps:")
fmt.Println(" 1. Edit status_map in the Modulos UI: map kanban column names to Jira statuses")
fmt.Println(" (e.g. \"In Progress\" → \"In Progress\", \"Done\" → \"Done\")")
fmt.Println(" 2. Click \"Test\" in the UI to verify board 33 belongs to project DATA")
fmt.Println(" 3. Move a card in kanban — push should hit Jira REST API")
return nil
}
// passShow shells out to pass(1) to read a secret. We do not cache or print
// the value; just trim trailing whitespace before returning.
func passShow(entry string) (string, error) {
out, err := exec.Command("pass", "show", entry).Output()
if err != nil {
return "", fmt.Errorf("pass show %s: %w", entry, err)
}
return strings.TrimSpace(string(out)), nil
}
+133 -23
View File
@@ -19,8 +19,15 @@ func errResult(err error) ToolResult { return ToolResult{OK: false, Error: err.
func errMsg(msg string) ToolResult { return ToolResult{OK: false, Error: msg} }
// executeTool dispatches a tool by name with raw JSON input and returns a ToolResult.
// Tools that mutate the board return ok=true on success; read-only tools include their data in result.
// Used by the legacy chat path (no authenticated user available).
func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
return executeToolAs(db, name, input, "")
}
// executeToolAs is the actor-aware dispatch used by the HTTP MCP path.
// actor is the authenticated user id (resolved from the bearer token) for tools
// that need it (add_comment / delete_comment infer the author from it).
func executeToolAs(db *DB, name string, input json.RawMessage, actor string) ToolResult {
switch name {
case "list_board":
return toolListBoard(db)
@@ -50,6 +57,14 @@ func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
return toolListUsers(db)
case "assign_card":
return toolAssignCard(db, input)
case "get_card":
return toolGetCard(db, input)
case "add_comment":
return toolAddCommentAs(db, input, actor)
case "list_comments":
return toolListComments(db, input)
case "delete_comment":
return toolDeleteComment(db, input, actor)
default:
return errMsg("unknown tool: " + name)
}
@@ -59,7 +74,8 @@ func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
func toolMutates(name string) bool {
switch name {
case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns",
"create_card", "update_card", "delete_card", "move_card", "assign_card":
"create_card", "update_card", "delete_card", "move_card", "assign_card",
"add_comment", "delete_comment":
return true
}
return false
@@ -339,27 +355,6 @@ func toolFindCards(db *DB, input json.RawMessage) ToolResult {
return okResult(out)
}
// chatActionsRegex matches an <actions>...</actions> block (DOTALL mode).
// Used by chat.go to extract tool invocations from the assistant's response.
var actionsBlockMarker = struct{ Open, Close string }{Open: "<actions>", Close: "</actions>"}
func extractActions(text string) (jsonBlock string, stripped string, found bool) {
openIdx := strings.Index(text, actionsBlockMarker.Open)
if openIdx < 0 {
return "", text, false
}
closeIdx := strings.Index(text[openIdx:], actionsBlockMarker.Close)
if closeIdx < 0 {
return "", text, false
}
closeIdx += openIdx
jsonBlock = strings.TrimSpace(text[openIdx+len(actionsBlockMarker.Open) : closeIdx])
before := strings.TrimRight(text[:openIdx], " \n\t")
after := strings.TrimLeft(text[closeIdx+len(actionsBlockMarker.Close):], " \n\t")
stripped = strings.TrimSpace(before + "\n" + after)
return jsonBlock, stripped, true
}
// validateToolName fails fast with clearer error than the dispatch's default.
func validateToolName(name string) error {
known := map[string]bool{
@@ -368,9 +363,124 @@ func validateToolName(name string) error {
"update_card": true, "delete_card": true, "move_card": true,
"card_history": true, "find_cards": true,
"list_users": true, "assign_card": true,
"add_comment": true, "list_comments": true, "delete_comment": true,
"get_card": true,
}
if !known[name] {
return fmt.Errorf("unknown tool: %s", name)
}
return nil
}
// toolAddCommentAs appends a comment (card_message) to a card.
//
// Author resolution order:
// 1. explicit "author_id" in input (legacy chat path)
// 2. explicit "author_username" in input -> resolve to id
// 3. fallback to `actor` (authenticated user from MCP HTTP token)
//
// At least one must yield a non-empty id.
func toolAddCommentAs(db *DB, input json.RawMessage, actor string) ToolResult {
var in struct {
CardID string `json:"card_id"`
Body string `json:"body"`
AuthorID string `json:"author_id"`
AuthorUsername string `json:"author_username"`
}
if err := json.Unmarshal(input, &in); err != nil {
return errResult(err)
}
if in.CardID == "" {
return errMsg("card_id required")
}
if strings.TrimSpace(in.Body) == "" {
return errMsg("body required")
}
authorID := strings.TrimSpace(in.AuthorID)
if authorID == "" && in.AuthorUsername != "" {
u, _, err := db.GetUserByUsername(in.AuthorUsername)
if err != nil {
return errResult(fmt.Errorf("author_username: %w", err))
}
authorID = u.ID
}
if authorID == "" {
authorID = actor
}
if authorID == "" {
return errMsg("author_id, author_username, or authenticated MCP token required")
}
m, err := db.CreateCardMessage(in.CardID, authorID, in.Body)
if err != nil {
return errResult(err)
}
return okResult(m)
}
// toolGetCard returns a single active (non-archived) card by id or seq_num.
// Pass exactly ONE of {id, seq_num}.
func toolGetCard(db *DB, input json.RawMessage) ToolResult {
var in struct {
ID string `json:"id"`
SeqNum int `json:"seq_num"`
}
if err := json.Unmarshal(input, &in); err != nil {
return errResult(err)
}
if in.ID == "" && in.SeqNum == 0 {
return errMsg("provide id or seq_num")
}
cards, err := db.ListCardsWithTime()
if err != nil {
return errResult(err)
}
for _, c := range cards {
if in.ID != "" && c.ID == in.ID {
return okResult(c)
}
if in.SeqNum != 0 && c.SeqNum == in.SeqNum {
return okResult(c)
}
}
return errMsg("card not found")
}
// toolDeleteComment deletes a comment. Only the original author can delete it
// (enforced via actor == message.author_id).
func toolDeleteComment(db *DB, input json.RawMessage, actor string) ToolResult {
if actor == "" {
return errMsg("authenticated user required (call via MCP HTTP with a valid token)")
}
var in struct {
ID string `json:"id"`
}
if err := json.Unmarshal(input, &in); err != nil {
return errResult(err)
}
if in.ID == "" {
return errMsg("id required")
}
if err := db.DeleteCardMessage(in.ID, actor); err != nil {
return errResult(err)
}
return okResult(map[string]bool{"ok": true})
}
// toolListComments returns every comment (card_message) attached to a card
// sorted by created_at ascending.
func toolListComments(db *DB, input json.RawMessage) ToolResult {
var in struct {
CardID string `json:"card_id"`
}
if err := json.Unmarshal(input, &in); err != nil {
return errResult(err)
}
if in.CardID == "" {
return errMsg("card_id required")
}
msgs, err := db.ListCardMessages(in.CardID)
if err != nil {
return errResult(err)
}
return okResult(msgs)
}
+2 -33
View File
@@ -256,7 +256,7 @@ func TestExecuteTool_MoveCard_BetweenColumns_OpensHistory(t *testing.T) {
histRes := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID}))
mustOK(t, histRes)
hist := histRes.Result.([]HistoryEntry)
hist := histRes.Result.(*CardHistoryResponse).ColumnHistory
if len(hist) != 2 {
t.Fatalf("expected 2 history entries, got %d", len(hist))
}
@@ -286,7 +286,7 @@ func TestExecuteTool_CardHistory_Single(t *testing.T) {
res := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID}))
mustOK(t, res)
hist := res.Result.([]HistoryEntry)
hist := res.Result.(*CardHistoryResponse).ColumnHistory
if len(hist) != 1 || hist[0].ExitedAt != nil {
t.Fatalf("expected 1 open history entry, got %+v", hist)
}
@@ -340,37 +340,6 @@ func TestExecuteTool_Unknown(t *testing.T) {
mustErr(t, res, "unknown tool")
}
// --- extractActions ---
func TestExtractActions(t *testing.T) {
cases := []struct {
name string
in string
want string
stripOK string
found bool
}{
{"with block", "Hola\n<actions>[{\"tool\":\"x\"}]</actions>\nHecho", `[{"tool":"x"}]`, "Hola\nHecho", true},
{"only block", "<actions>[]</actions>", `[]`, "", true},
{"no block", "Solo texto", "", "Solo texto", false},
{"unclosed", "<actions>foo", "", "<actions>foo", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, stripped, found := extractActions(c.in)
if found != c.found {
t.Fatalf("found = %v want %v", found, c.found)
}
if got != c.want {
t.Fatalf("got %q want %q", got, c.want)
}
if stripped != c.stripOK {
t.Fatalf("stripped = %q want %q", stripped, c.stripOK)
}
})
}
}
// --- chat logger ---
func TestChatLogger_AppendsJSONLines(t *testing.T) {
+25 -6
View File
@@ -14,6 +14,7 @@ type User struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Color string `json:"color"`
IsAdmin bool `json:"is_admin"`
CreatedAt string `json:"created_at"`
}
@@ -51,36 +52,52 @@ func (db *DB) CreateUser(username, password, displayName string) (*User, error)
func (db *DB) GetUserByID(id string) (*User, error) {
var u User
var isAdmin int
err := db.conn.QueryRow(
`SELECT id, username, display_name, color, created_at FROM users WHERE id=?`, id,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt)
`SELECT id, username, display_name, color, is_admin, created_at FROM users WHERE id=?`, id,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &isAdmin, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, errUserNotFound
}
if err != nil {
return nil, err
}
u.IsAdmin = isAdmin == 1
return &u, nil
}
func (db *DB) IsAdmin(userID string) (bool, error) {
if userID == "" {
return false, nil
}
var n int
err := db.conn.QueryRow(`SELECT COALESCE(is_admin, 0) FROM users WHERE id=?`, userID).Scan(&n)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return n == 1, err
}
func (db *DB) GetUserByUsername(username string) (*User, string, error) {
username = strings.TrimSpace(strings.ToLower(username))
var u User
var hash string
var isAdmin int
err := db.conn.QueryRow(
`SELECT id, username, display_name, color, created_at, password_hash FROM users WHERE username=?`, username,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt, &hash)
`SELECT id, username, display_name, color, is_admin, created_at, password_hash FROM users WHERE username=?`, username,
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &isAdmin, &u.CreatedAt, &hash)
if errors.Is(err, sql.ErrNoRows) {
return nil, "", errUserNotFound
}
if err != nil {
return nil, "", err
}
u.IsAdmin = isAdmin == 1
return &u, hash, nil
}
func (db *DB) ListUsers() ([]User, error) {
rows, err := db.conn.Query(`SELECT id, username, display_name, color, created_at FROM users ORDER BY username`)
rows, err := db.conn.Query(`SELECT id, username, display_name, color, is_admin, created_at FROM users ORDER BY username`)
if err != nil {
return nil, err
}
@@ -88,9 +105,11 @@ func (db *DB) ListUsers() ([]User, error) {
out := []User{}
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt); err != nil {
var isAdmin int
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &isAdmin, &u.CreatedAt); err != nil {
return nil, err
}
u.IsAdmin = isAdmin == 1
out = append(out, u)
}
return out, rows.Err()
Executable
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env bash
# Kanban control TUI — gestiona backend (WSL) + frontend Vite (Windows) desde WSL.
# Lanzamientos fire-and-forget; status panel auto-refresca cada 2s.
# Lanzar: ./control.sh
set -u
BACKEND_PORT=8095
FRONTEND_PORT=5180
APP_DIR="/home/egutierrez/fn_registry/apps/kanban"
BACKEND_LOG="/tmp/kanban.log"
BUILD_LOG="/tmp/kanban_build.log"
MSG_FILE="/tmp/kanban_control.msg"
WIN_FRONT_DIR='C:\Users\egutierrez\fn_apps\kanban\frontend'
RED=$'\033[31m'; GRN=$'\033[32m'; YLW=$'\033[33m'; CYN=$'\033[36m'; BLD=$'\033[1m'; RST=$'\033[0m'
msg() { printf '%s\n' "$*" > "$MSG_FILE"; }
wsl_pid_on_port() {
local port=$1
ss -ltnp 2>/dev/null | awk -v p=":$port\$" '$4 ~ p {print $0}' \
| grep -oP 'pid=\K[0-9]+' | head -1
}
win_pid_on_port() {
local port=$1
netstat.exe -ano 2>/dev/null | tr -d '\r' \
| awk -v p=":$port\$" '$2 ~ p && $4 == "LISTENING" {print $5; exit}'
}
backend_building() {
[[ -f /tmp/kanban_build.pid ]] && kill -0 "$(cat /tmp/kanban_build.pid 2>/dev/null)" 2>/dev/null
}
# Build + launch en background — retorna inmediatamente
start_backend() {
if [[ -n $(wsl_pid_on_port "$BACKEND_PORT") ]]; then
msg "${YLW}backend ya corriendo${RST}"; return 0
fi
if backend_building; then
msg "${YLW}backend ya esta compilando, espera${RST}"; return 0
fi
local version
version=$(awk -F': ' '/^version:/ {print $2; exit}' "$APP_DIR/app.md" 2>/dev/null || echo dev)
msg "${CYN}lanzando backend en background (version=$version)...${RST}"
(
cd "$APP_DIR/backend" || exit 1
# Rebuild si: binario no existe, .go/.sql mas nuevos, app.md mas nuevo (bump de version)
if [[ ! -x kanban ]] \
|| [[ -n $(find . -maxdepth 3 \( -name '*.go' -o -name '*.sql' \) -newer kanban 2>/dev/null) ]] \
|| [[ "$APP_DIR/app.md" -nt kanban ]]; then
CGO_ENABLED=1 go build -tags fts5 \
-ldflags="-X main.Version=$version" \
-o kanban . > "$BUILD_LOG" 2>&1 || {
printf 'build failed — ver %s\n' "$BUILD_LOG" > "$MSG_FILE"
exit 1
}
fi
cd "$APP_DIR" || exit 1
KANBAN_CLAUDE_BIN=/home/egutierrez/.local/bin/claude \
setsid nohup ./backend/kanban --port "$BACKEND_PORT" --db ./operations.db \
> "$BACKEND_LOG" 2>&1 < /dev/null &
disown
) &
echo $! > /tmp/kanban_build.pid
disown
}
stop_backend() {
local pid
pid=$(wsl_pid_on_port "$BACKEND_PORT")
if [[ -z $pid ]]; then
msg "${YLW}backend ya parado${RST}"; return 0
fi
kill "$pid" 2>/dev/null
( sleep 1; kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null ) &
disown
msg "${GRN}backend stopped (pid $pid)${RST}"
}
wsl_ip() { hostname -I | awk '{print $1}'; }
# WSL frontend → Windows frontend (excluye node_modules, dist, .vite)
sync_frontend() {
local src="$APP_DIR/frontend/"
local dst="/mnt/c/Users/egutierrez/fn_apps/kanban/frontend/"
if [[ ! -d $dst ]]; then
msg "${RED}no existe $dst${RST}"; return 1
fi
rsync -a --delete \
--exclude node_modules --exclude dist --exclude .vite \
--exclude .cache --exclude tsconfig.tsbuildinfo \
"$src" "$dst" 2>&1 | tail -3
# pnpm install si package.json cambio
if ! cmp -s "$src/package.json" "$dst/package.json" 2>/dev/null \
|| [[ ! -d "$dst/node_modules" ]]; then
msg "${CYN}deps cambiaron, lanza pnpm install en Windows...${RST}"
cmd.exe /c start "" cmd /c "cd /d $WIN_FRONT_DIR && pnpm install" >/dev/null 2>&1 &
disown
fi
}
# Lanza ventana cmd Windows con pnpm dev — no bloquea
# Inyecta VITE_API_TARGET con IP WSL real porque localhost forwarding Win→WSL no es fiable
start_vite() {
if [[ -n $(win_pid_on_port "$FRONTEND_PORT") ]]; then
msg "${YLW}vite ya corriendo${RST}"; return 0
fi
sync_frontend
local ip target
ip=$(wsl_ip)
target="http://${ip}:${BACKEND_PORT}"
cmd.exe /c start "" cmd /c "cd /d $WIN_FRONT_DIR && set VITE_API_TARGET=$target && pnpm dev --port $FRONTEND_PORT --strictPort --host" >/dev/null 2>&1 &
disown
msg "${CYN}vite lanzado, proxy → $target${RST}"
}
stop_vite() {
local pid
pid=$(win_pid_on_port "$FRONTEND_PORT")
if [[ -z $pid ]]; then
msg "${YLW}vite ya parado${RST}"; return 0
fi
taskkill.exe /F /T /PID "$pid" >/dev/null 2>&1 &
disown
msg "${GRN}taskkill enviado a vite pid $pid${RST}"
}
kill_stale() {
local found=0 out=""
for pid in $(pgrep -f "backend/kanban --port" 2>/dev/null); do
local cmdl
cmdl=$(tr '\0' ' ' < /proc/$pid/cmdline 2>/dev/null)
if ! grep -q -- "--port $BACKEND_PORT" <<<"$cmdl"; then
kill -9 "$pid" 2>/dev/null
out+="killed wsl pid $pid ($cmdl); "
found=1
fi
done
[[ $found -eq 0 ]] && msg "${GRN}sin huerfanos WSL${RST}" || msg "${GRN}${out}${RST}"
}
_prev_frame=""
build_frame() {
local bpid vpid hc others
bpid=$(wsl_pid_on_port "$BACKEND_PORT")
vpid=$(win_pid_on_port "$FRONTEND_PORT")
local out=""
out+=$(printf '%s=== Kanban control ===%s' "$BLD" "$RST")$'\n\n'
if [[ -n $bpid ]]; then
local rv av
rv=$(curl -s -m 1 "http://127.0.0.1:$BACKEND_PORT/api/version" | grep -oP '"version":"\K[^"]+' || echo "?")
av=$(awk -F': ' '/^version:/ {print $2; exit}' "$APP_DIR/app.md" 2>/dev/null || echo "?")
if [[ "$rv" == "$av" ]]; then
hc="${GRN}v$rv${RST}"
else
hc="${YLW}running=v$rv app.md=v$av (rebuild)${RST}"
fi
out+=$(printf ' backend (WSL :%s) %sUP%s pid %s %s' \
"$BACKEND_PORT" "$GRN" "$RST" "$bpid" "$hc")$'\n'
elif backend_building; then
out+=$(printf ' backend (WSL :%s) %sBUILDING/STARTING%s tail %s' \
"$BACKEND_PORT" "$YLW" "$RST" "$BUILD_LOG")$'\n'
else
out+=$(printf ' backend (WSL :%s) %sDOWN%s' "$BACKEND_PORT" "$RED" "$RST")$'\n'
fi
# frontend version + drift WSL↔Win
local fv drift
fv=$(grep -oP '"version":\s*"\K[^"]+' "$APP_DIR/frontend/package.json" 2>/dev/null || echo "?")
drift=$(diff -rq "$APP_DIR/frontend/src" "/mnt/c/Users/egutierrez/fn_apps/kanban/frontend/src" 2>/dev/null \
| grep -c -E "^(Files|Only)" || true)
local dlbl
if [[ ${drift:-0} -eq 0 ]]; then
dlbl="${GRN}sync${RST}"
else
dlbl="${YLW}drift=$drift (sync al start)${RST}"
fi
if [[ -n $vpid ]]; then
out+=$(printf ' vite (WIN :%s) %sUP%s pid %s v%s %s' "$FRONTEND_PORT" "$GRN" "$RST" "$vpid" "$fv" "$dlbl")$'\n'
else
out+=$(printf ' vite (WIN :%s) %sDOWN%s v%s %s' "$FRONTEND_PORT" "$RED" "$RST" "$fv" "$dlbl")$'\n'
fi
others=$(pgrep -af "backend/kanban --port" 2>/dev/null | grep -v -- "--port $BACKEND_PORT" || true)
if [[ -n $others ]]; then
out+=$(printf ' %sOTROS kanban backends WSL:%s' "$YLW" "$RST")$'\n'
out+=$(echo "$others" | sed 's/^/ /')$'\n'
fi
out+=$'\n'
out+=$(printf '%sUltimo evento:%s %s' "$CYN" "$RST" "$(tail -1 "$MSG_FILE" 2>/dev/null || echo '-')")$'\n\n'
out+="${BLD}Acciones${RST} (auto-refresh 2s, tecla suelta):"$'\n'
out+=" 1) Start backend 5) Start TODO"$'\n'
out+=" 2) Stop backend 6) Stop TODO"$'\n'
out+=" 3) Start vite 7) Mata kanban huerfanos"$'\n'
out+=" 4) Stop vite 8) Tail backend log"$'\n'
out+=" 9) Refrescar 0) Salir"$'\n'
out+="> "
printf '%s' "$out"
}
draw_status() {
local frame
frame=$(build_frame)
if [[ $frame == "$_prev_frame" ]]; then
return 0
fi
_prev_frame=$frame
# cursor home + frame + erase-to-end-of-display (limpia lineas residuales)
printf '\033[H%s\033[J' "$frame"
}
tail_log() {
clear
printf '%stail -f %s (Ctrl-C vuelve al menu)%s\n' "$CYN" "$BACKEND_LOG" "$RST"
trap 'trap - INT; return 0' INT
tail -f "$BACKEND_LOG" 2>/dev/null
trap - INT
}
menu() {
: > "$MSG_FILE"
# limpia pantalla una sola vez; redraw posterior usa cursor-home
printf '\033[2J\033[H'
trap 'printf "\033[?25h\n"; exit 0' EXIT INT TERM
printf '\033[?25l' # oculta cursor mientras dibujamos
while true; do
draw_status
# read con timeout 2s — refresco automatico si no hay tecla
local choice=""
if read -rsn1 -t 2 choice; then
case "$choice" in
1) start_backend ;;
2) stop_backend ;;
3) start_vite ;;
4) stop_vite ;;
5) start_backend; start_vite ;;
6) stop_vite; stop_backend ;;
7) kill_stale ;;
8) printf '\033[?25h'; tail_log; printf '\033[?25l'; _prev_frame=""; printf '\033[2J\033[H' ;;
9) : ;;
0|q|Q) printf '\033[?25h'; clear; exit 0 ;;
$'\n'|"") : ;;
*) msg "${RED}opcion invalida: $choice${RST}" ;;
esac
fi
done
}
menu
+11
View File
@@ -0,0 +1,11 @@
{
"flags": {
"registration-enabled": {
"enabled": false,
"issue": null,
"description": "Allows new users to register via POST /api/auth/register and the LoginPage register toggle.",
"added": "2026-05-12",
"enabled_at": null
}
}
}
+22
View File
@@ -0,0 +1,22 @@
name: kanban
services:
kanban:
build:
context: ../../
dockerfile: apps/kanban/Dockerfile
container_name: kanban
restart: unless-stopped
ports:
- "8095:8095"
volumes:
- kanban_data:/data
networks:
- coolify
volumes:
kanban_data:
networks:
coolify:
external: true
+132
View File
@@ -0,0 +1,132 @@
# Prompt para instalar el MCP del kanban en Claude Code
Este documento esta escrito **para Claude**, no para un humano. Pegalo en tu sesion de Claude Code junto con tu URL y tu token, y Claude registrara el MCP server `kanban` por ti, verificara la conexion y te dira como invocarlo.
---
## Contexto
El kanban (https://github.com/... — repo interno) expone un endpoint **MCP Streamable HTTP** en `/mcp` con autenticacion Bearer por usuario. Cada usuario genera su propio token en la UI ("avatar menu → MCP tokens → Generar"). El token cifrado no se recupera: si se pierde, hay que generar otro y revocar el anterior.
El MCP server expone 14 tools para gestionar el board:
| Tool | Que hace |
|---|---|
| `list_board` | Lista columnas y tarjetas |
| `find_cards` | Busca por texto/columna/solicitante |
| `create_card` / `update_card` / `delete_card` | CRUD de tarjetas |
| `move_card` | Mueve tarjeta a otra columna |
| `assign_card` | Asigna o desasigna usuario |
| `card_history` | Cambios historicos de una tarjeta |
| `create_column` / `update_column` / `rename_column` / `delete_column` / `reorder_columns` | Admin de columnas |
| `list_users` | Lista usuarios disponibles |
---
## Datos que necesitas pedir al usuario (o que recibes con el prompt)
- `MCP_URL`: URL completa del endpoint, por ejemplo `http://192.168.1.50:5180/mcp`. Suele ser el mismo host:puerto donde el usuario abre el kanban en el navegador + sufijo `/mcp`.
- `MCP_TOKEN`: token que empieza por `kmcp_` seguido de 64 caracteres hex. El usuario lo copio del modal "MCP tokens".
Si el usuario no te ha dado uno o ambos, pidelo antes de continuar. NO inventes valores ni uses placeholders.
---
## Pasos que tienes que ejecutar
### 1. Comprobar prerequisitos
```bash
claude --version
claude mcp list 2>&1 | head
```
Si `claude` no esta en el PATH, indica al usuario que instale Claude Code antes de continuar (no intentes instalarlo tu).
### 2. Registrar el MCP server
```bash
claude mcp add kanban \
--transport http \
--header "Authorization: Bearer ${MCP_TOKEN}" \
-- ${MCP_URL}
```
Notas:
- Si ya existe un MCP `kanban` previo, primero `claude mcp remove kanban` y luego registra el nuevo. NO intentes editarlo en place.
- Si el comando devuelve error sobre `--transport`, comprueba que la version de Claude Code soporta MCP HTTP (>= 2.0.0). En versiones antiguas usa `mcp-remote` como bridge:
```bash
claude mcp add kanban -- npx -y mcp-remote "${MCP_URL}" --header "Authorization: Bearer ${MCP_TOKEN}"
```
### 3. Verificar conexion
```bash
claude mcp list
```
Tiene que aparecer una linea como:
```
kanban http ✓ connected
```
Si aparece `✗ failed` o un error de conexion, comprueba:
- Que el host del kanban es accesible desde esta maquina (`curl -s -o /dev/null -w '%{http_code}\n' ${MCP_URL}` debe devolver `405` — es POST-only).
- Que el token no caduco ni fue revocado.
- Que la URL termina exactamente en `/mcp` (sin barra final).
### 4. Probar una llamada real
```bash
claude -p "Usa la tool mcp__kanban__list_board y dime cuantas columnas tiene mi tablero y cuantas tarjetas hay en total." \
--allowed-tools mcp__kanban__list_board
```
Output esperado: un resumen en lenguaje natural con el numero de columnas y tarjetas. Si Claude responde "no tengo acceso a esa tool" o "MCP no esta configurado", vuelve al paso 2.
### 5. Resumir al usuario
Cuando termines, dile al usuario:
- Si la conexion esta OK y el smoke test paso.
- Que tools tiene disponibles.
- Como invocarlas en futuras sesiones (por ejemplo: "crea una tarjeta para revisar el reporte mensual" o "muevela a la columna Doing").
- Como revocar el token si pierde el control de esta maquina.
---
## Errores frecuentes
| Sintoma | Causa probable | Accion |
|---|---|---|
| `claude mcp add` no acepta `--transport http` | Version vieja de Claude Code | Usar `mcp-remote` (ver paso 2). |
| `connection refused` | El kanban no esta corriendo o el puerto cambio | Confirmar con el usuario que abre el kanban en el navegador. |
| `401 unauthorized` | Token mal copiado o revocado | Generar nuevo token en la UI, repetir paso 2. |
| `405 Method Not Allowed` en smoke test | URL apuntando a un GET en vez de POST | El endpoint es POST-only; el flujo de `claude mcp` lo gestiona, pero un `curl` manual con GET fallara. |
| Tools no aparecen tras instalar | Sesion de Claude Code cacheo la config vieja | Cierra y vuelve a abrir Claude Code. |
---
## Que NO hacer
- No escribas el token en plain text en ningun archivo del repositorio del usuario, ni en logs, ni en commits, ni en mensajes que persistan.
- No intentes "probar" el token llamando al endpoint con `curl` y pegandolo visible — solo usa el comando `claude mcp add`.
- No modifiques `~/.claude.json` a mano; usa siempre `claude mcp add/remove`.
- No expongas el endpoint `/mcp` a redes mas amplias que las del usuario sin consultarle.
- No crees, modifiques ni borres tarjetas durante el smoke test salvo que el usuario lo pida explicitamente. Usa solo `list_board` para validar.
---
## Si algo no esta claro
Pidele al usuario:
- El URL exacto que abre en el navegador para usar el kanban (sin `/mcp`; lo añades tu).
- El token recien generado (NO uno viejo).
- La version de Claude Code (`claude --version`).
- El SO en el que esta (`uname -a` o, en Windows, `ver`).
Con eso puedes terminar la instalacion en menos de un minuto.
+79
View File
@@ -0,0 +1,79 @@
# Conectar Claude al kanban via MCP
El kanban expone un endpoint **MCP HTTP** (`/mcp`) que permite a un cliente Claude leer y modificar el tablero de cada usuario.
## Cuando usarlo
- Pedir a Claude que cree, actualice, mueva o busque tarjetas desde tu terminal local sin abrir el navegador.
- Listar el board en lenguaje natural.
- Asignar tarjetas, consultar historial, etc.
## Configuracion (una vez por PC)
### 1. Generar token en el kanban
1. Abre el kanban en el navegador (mismo URL que usas normalmente, por ejemplo `http://<host-windows>:5180`).
2. Click en tu avatar (esquina superior derecha) → **MCP tokens**.
3. Pulsa **Generar**, dale un nombre descriptivo (por ejemplo `portatil-trabajo`).
4. **Copia el token inmediatamente** — solo se muestra una vez. Tambien tendras el comando `claude mcp add` listo para pegar.
### 2. Registrar el MCP en Claude Code
En el PC desde el que vas a usar Claude:
```bash
claude mcp add kanban --transport http http://<host-windows>:5180/mcp \
--header "Authorization: Bearer kmcp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```
Reemplaza `<host-windows>` por la IP o nombre del PC Windows que sirve el kanban en la LAN, y el token por el valor que copiaste.
Verifica con:
```bash
claude mcp list
```
Tienes que ver `kanban` con estado **connected**.
## Tools disponibles
Una vez conectado, Claude puede invocar:
| Tool | Que hace |
|---|---|
| `list_board` | Devuelve columnas y tarjetas del tablero |
| `create_column` | Crea una columna nueva |
| `update_column` | Modifica nombre, ancho, WIP, ubicacion, terminal |
| `rename_column` | Alias rapido de `update_column` con `{id, name}` |
| `delete_column` | Borra una columna (cards a papelera) |
| `reorder_columns` | Reordena columnas |
| `create_card` | Crea tarjeta en una columna |
| `update_card` | Edita titulo, descripcion, color, lock, asignado |
| `delete_card` | Envia tarjeta a papelera |
| `move_card` | Mueve tarjeta a otra columna |
| `card_history` | Historial de cambios de una tarjeta |
| `find_cards` | Busca por texto/columna/solicitante |
| `list_users` | Usuarios disponibles para asignar |
| `assign_card` | Asigna o desasigna usuario |
## Revocar acceso
Si pierdes el PC o quieres rotar el token, vuelve al modal **MCP tokens** y pulsa el icono de papelera en la fila correspondiente. El cliente Claude perdera acceso al instante.
## Limitaciones actuales
- Las acciones por MCP no registran `actor_id` en el historial — quedan como anonimas. (Mejora pendiente.)
- No hay rate limiting por token; revoca si detectas mal uso.
- El endpoint NO soporta SSE server→client (solicitudes Claude→kanban funcionan, sin streaming inverso).
- Solo POST `/mcp` esta soportado; GET y DELETE devuelven 405.
- Body limit 1 MiB.
## Troubleshooting
| Sintoma | Probable causa |
|---|---|
| `claude mcp list` muestra error de conexion | Vite (puerto 5180) o backend (8095) parados. Lanza el `control.sh` del kanban. |
| `401 unauthorized` | Token mal pegado, revocado, o caducado. Genera uno nuevo. |
| `405 Method Not Allowed` | Estas haciendo GET; el MCP solo acepta POST. |
| Tools listan pero `list_board` falla | Backend devuelve error real — mira `kanban.log` en WSL. |
+353
View File
@@ -0,0 +1,353 @@
// E2E test que arranca el binario kanban en un puerto efimero con un fake
// claude script y verifica que /api/chat/ws emite los eventos esperados.
//
// Cubre el bug del proxy Vite (ws: true) tambien en el camino directo: la
// conexion WebSocket se establece contra el puerto del backend y atraviesa
// el upgrade real de nhooyr.io/websocket.
//
// Ejecucion:
// cd apps/kanban/e2e && go test -v -run TestE2E_ChatWS ./...
//
// Build artifacts: el test compila el binario kanban en un dir temporal.
// El fake claude tambien se escribe en ese dir.
package e2e
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"nhooyr.io/websocket"
)
// buildKanbanBinary compila el backend en un binario temporal y retorna su
// path. Se compila una vez por test.
func buildKanbanBinary(t *testing.T) string {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "kanban")
cmd := exec.Command("go", "build", "-tags", "fts5", "-o", bin, ".")
cmd.Dir = "../backend"
cmd.Env = append(os.Environ(), "CGO_ENABLED=1")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go build kanban: %v\n%s", err, out)
}
return bin
}
// fakeClaudeScript writes a bash script that emits NDJSON stream-json events
// and exits 0.
func fakeClaudeBin(t *testing.T, payload string) string {
t.Helper()
if _, err := os.Stat("/bin/bash"); err != nil {
t.Skip("/bin/bash not available")
}
dir := t.TempDir()
path := filepath.Join(dir, "claude")
body := "#!/bin/bash\nset -e\ncat <<'__EOF__'\n" + payload + "\n__EOF__\n"
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
t.Fatalf("write fake claude: %v", err)
}
return path
}
// freePort returns a random unused TCP port.
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// kanbanLauncher starts kanban on a random port with a fake claude binary
// and a fresh DB. Returns the URL and a cleanup func.
type kanbanLauncher struct {
URL string
Port int
DBDir string
Cancel context.CancelFunc
Stderr *bytes.Buffer
Stdout *bytes.Buffer
}
func startKanban(t *testing.T, fakeClaude string) *kanbanLauncher {
t.Helper()
bin := buildKanbanBinary(t)
dbDir := t.TempDir()
dbPath := filepath.Join(dbDir, "kanban.db")
port := freePort(t)
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, bin,
"--port", fmt.Sprint(port),
"--db", dbPath,
"--initial-admin", "e2e:e2etest",
)
cmd.Env = append(os.Environ(),
"KANBAN_CLAUDE_BIN="+fakeClaude,
"KANBAN_LISTEN_PORT="+fmt.Sprint(port),
"KANBAN_INITIAL_ADMIN=e2e:e2etest",
)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
cancel()
t.Fatalf("start kanban: %v", err)
}
l := &kanbanLauncher{
URL: fmt.Sprintf("http://127.0.0.1:%d", port),
Port: port,
DBDir: dbDir,
Cancel: cancel,
Stdout: stdout,
Stderr: stderr,
}
// Espera a que el server responda /api/board (publico-ish: 401 sin sesion).
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
resp, err := http.Get(l.URL + "/api/board")
if err == nil {
resp.Body.Close()
t.Cleanup(func() {
cancel()
_ = cmd.Wait()
if t.Failed() {
t.Logf("kanban stdout:\n%s", stdout.String())
t.Logf("kanban stderr:\n%s", stderr.String())
}
})
return l
}
time.Sleep(150 * time.Millisecond)
}
cancel()
_ = cmd.Wait()
t.Fatalf("kanban no respondio en 15s\nstdout:\n%s\nstderr:\n%s", stdout.String(), stderr.String())
return nil
}
// loginGetCookie performs API login and returns the cookie jar with session.
func loginGetCookie(t *testing.T, baseURL string) http.CookieJar {
t.Helper()
jar, _ := cookiejar.New(nil)
cli := &http.Client{Jar: jar, Timeout: 5 * time.Second}
body, _ := json.Marshal(map[string]string{"username": "e2e", "password": "e2etest"})
resp, err := cli.Post(baseURL+"/api/auth/login", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("login: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
buf, _ := io.ReadAll(resp.Body)
t.Fatalf("login status %d: %s", resp.StatusCode, buf)
}
return jar
}
// dialWS opens a WebSocket using the session cookie from the jar.
func dialWS(t *testing.T, baseURL string, jar http.CookieJar) *websocket.Conn {
t.Helper()
u, _ := url.Parse(baseURL)
wsURL := "ws://" + u.Host + "/api/chat/ws"
hdr := http.Header{}
for _, c := range jar.Cookies(u) {
hdr.Add("Cookie", c.Name+"="+c.Value)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: hdr})
if err != nil {
t.Fatalf("ws dial %s: %v", wsURL, err)
}
return conn
}
type wsMsg struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Tool string `json:"tool,omitempty"`
ToolID string `json:"tool_id,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Result string `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
BoardChanged bool `json:"board_changed,omitempty"`
Error string `json:"error,omitempty"`
}
func readMsg(t *testing.T, conn *websocket.Conn) wsMsg {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, data, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read: %v", err)
}
var m wsMsg
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("unmarshal %q: %v", string(data), err)
}
return m
}
func writeMsg(t *testing.T, conn *websocket.Conn, payload any) {
t.Helper()
body, _ := json.Marshal(payload)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := conn.Write(ctx, websocket.MessageText, body); err != nil {
t.Fatalf("write: %v", err)
}
}
// --- Tests ----------------------------------------------------------------
// TestE2E_ChatWS_StreamsTextAndCloses arranca el backend con fake claude
// que emite un mensaje de texto + result, abre WebSocket via cookie de
// sesion y comprueba que llegan deltas + done.
func TestE2E_ChatWS_StreamsTextAndCloses(t *testing.T) {
payload := `{"type":"system","subtype":"init","session_id":"s1","model":"test"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hola "}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"mundo"}]}}
{"type":"result","subtype":"success","is_error":false,"result":"Hola mundo","stop_reason":"end_turn"}`
srv := startKanban(t, fakeClaudeBin(t, payload))
jar := loginGetCookie(t, srv.URL)
conn := dialWS(t, srv.URL, jar)
defer conn.Close(websocket.StatusNormalClosure, "")
writeMsg(t, conn, map[string]any{
"messages": []map[string]string{{"role": "user", "content": "saluda"}},
})
var deltas []string
var sawDone bool
for i := 0; i < 12 && !sawDone; i++ {
m := readMsg(t, conn)
switch m.Type {
case "delta":
deltas = append(deltas, m.Text)
case "done":
sawDone = true
case "error":
t.Fatalf("error event: %s", m.Error)
}
}
if !sawDone {
t.Fatalf("never received done event")
}
if got := strings.Join(deltas, ""); got != "Hola mundo" {
t.Fatalf("expected 'Hola mundo', got %q", got)
}
}
// TestE2E_ChatWS_ToolUseFlow comprueba que tool_use + tool_result se
// reenvian correctamente y que board_changed=true cuando la tool muta.
func TestE2E_ChatWS_ToolUseFlow(t *testing.T) {
payload := `{"type":"system","subtype":"init"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"mcp__kanban__create_column","input":{"name":"Backlog"}}]}}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"{\"ok\":true,\"result\":{\"id\":\"col_x\"}}","is_error":false}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Listo"}]}}
{"type":"result","subtype":"success","is_error":false,"result":"Listo","stop_reason":"end_turn"}`
srv := startKanban(t, fakeClaudeBin(t, payload))
jar := loginGetCookie(t, srv.URL)
conn := dialWS(t, srv.URL, jar)
defer conn.Close(websocket.StatusNormalClosure, "")
writeMsg(t, conn, map[string]any{
"messages": []map[string]string{{"role": "user", "content": "crea Backlog"}},
})
var sawToolUse, sawToolResult, sawDelta, sawDone, boardChanged bool
var toolName string
for i := 0; i < 16 && !sawDone; i++ {
m := readMsg(t, conn)
switch m.Type {
case "tool_use":
sawToolUse = true
toolName = m.Tool
if !strings.Contains(string(m.Input), "Backlog") {
t.Errorf("input missing Backlog: %s", m.Input)
}
case "tool_result":
sawToolResult = true
case "delta":
sawDelta = true
case "done":
sawDone = true
boardChanged = m.BoardChanged
case "error":
t.Fatalf("error: %s", m.Error)
}
}
if !sawToolUse || !sawToolResult || !sawDelta || !sawDone {
t.Fatalf("missing events: tool_use=%v tool_result=%v delta=%v done=%v",
sawToolUse, sawToolResult, sawDelta, sawDone)
}
if toolName != "create_column" {
t.Errorf("expected tool stripped to create_column, got %q", toolName)
}
if !boardChanged {
t.Errorf("expected board_changed=true")
}
}
// TestE2E_ChatWS_RejectsUnauthenticated: sin cookie de sesion el upgrade
// debe fallar con 401 (auth middleware /api/chat/ws).
func TestE2E_ChatWS_RejectsUnauthenticated(t *testing.T) {
payload := `{"type":"result","subtype":"success","is_error":false,"result":""}`
srv := startKanban(t, fakeClaudeBin(t, payload))
u, _ := url.Parse(srv.URL)
wsURL := "ws://" + u.Host + "/api/chat/ws"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _, err := websocket.Dial(ctx, wsURL, nil)
if err == nil {
t.Fatalf("expected dial to fail without cookie")
}
if !strings.Contains(err.Error(), "401") {
t.Fatalf("expected 401 in error, got: %v", err)
}
}
// TestE2E_InternalToolEndpoint_RealBackend: comprueba que el endpoint
// /api/tool/{name} esta vivo y rechaza tokens invalidos. Sirve para
// confirmar que la auth bypass + token check funciona en el binario
// real (no solo en httptest).
func TestE2E_InternalToolEndpoint_RealBackend(t *testing.T) {
payload := `{"type":"result","is_error":false,"result":""}`
srv := startKanban(t, fakeClaudeBin(t, payload))
// Sin token -> 401
req, _ := http.NewRequest("POST", srv.URL+"/api/tool/list_board", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
resp.Body.Close()
if resp.StatusCode != 401 {
t.Fatalf("expected 401 without token, got %d", resp.StatusCode)
}
}
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Issue 0128 — smoke test of card file attachments.
# Builds kanban (assumes ./kanban present or builds it), boots on ephemeral port,
# exercises POST upload, GET list, GET serve, DELETE, GET list-after-delete.
# Exits 0 on success, non-zero on any failure.
set -euo pipefail
PORT=${PORT:-18095}
BASE="http://127.0.0.1:${PORT}"
DB=$(mktemp /tmp/kanban_files_smoke.XXXXXX.db)
COOKIE=$(mktemp /tmp/kanban_files_smoke.cookie.XXXXXX)
UPLOAD_DIR=$(dirname "$DB")/uploads
PNG=$(mktemp /tmp/kanban_files_smoke.XXXXXX.png)
PID_FILE=$(mktemp /tmp/kanban_files_smoke.pid.XXXXXX)
cleanup() {
if [ -s "$PID_FILE" ]; then
kill "$(cat "$PID_FILE")" 2>/dev/null || true
fi
rm -f "$DB" "$DB-shm" "$DB-wal" "$COOKIE" "$PNG" "$PID_FILE"
rm -rf "$UPLOAD_DIR"
}
trap cleanup EXIT
# Build if missing.
if [ ! -x ./kanban ]; then
echo "[smoke] building kanban binary..."
(cd backend && CGO_ENABLED=1 go build -tags fts5 -o ../kanban .)
fi
# Boot.
./kanban --port "$PORT" --db "$DB" --initial-admin admin:adminpw \
> /tmp/kanban_files_smoke.log 2>&1 &
echo $! > "$PID_FILE"
# Wait for /api/board.
for _ in $(seq 1 30); do
if curl -sf -o /dev/null "$BASE/api/board"; then break; fi
sleep 0.2
done
# Login.
curl -sf -c "$COOKIE" -X POST "$BASE/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"adminpw"}' > /dev/null
# Column + card.
COL=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/columns" \
-H 'Content-Type: application/json' \
-d '{"name":"To Do"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
CARD=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/cards" \
-H 'Content-Type: application/json' \
-d "{\"column_id\":\"$COL\",\"title\":\"smoke\",\"requester\":\"r\"}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# Minimal PNG.
printf '\x89PNG\r\n\x1a\n' > "$PNG"
# Upload.
UP=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/cards/$CARD/files" \
-F "file=@$PNG;type=image/png")
FID=$(echo "$UP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
[ -n "$FID" ] || { echo "[smoke] upload missing id"; exit 1; }
# List active.
N=$(curl -sf -b "$COOKIE" "$BASE/api/cards/$CARD/files" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')
[ "$N" = "1" ] || { echo "[smoke] expected 1 file, got $N"; exit 1; }
# Serve.
CT=$(curl -sf -b "$COOKIE" -I "$BASE/api/files/$FID" | awk '/^[Cc]ontent-[Tt]ype/ {print $2}' | tr -d '\r\n')
echo "$CT" | grep -q image/png || { echo "[smoke] wrong content-type: $CT"; exit 1; }
# Delete.
HTTP=$(curl -sb "$COOKIE" -X DELETE -o /dev/null -w "%{http_code}" "$BASE/api/files/$FID")
[ "$HTTP" = "204" ] || { echo "[smoke] expected 204 on delete, got $HTTP"; exit 1; }
# List after delete.
N=$(curl -sf -b "$COOKIE" "$BASE/api/cards/$CARD/files" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')
[ "$N" = "0" ] || { echo "[smoke] expected 0 after delete, got $N"; exit 1; }
echo "[smoke] OK"
+4 -1
View File
@@ -2,6 +2,9 @@ module kanban-e2e
go 1.25.0
require fn-registry v0.0.0-00010101000000-000000000000
require (
fn-registry v0.0.0-00010101000000-000000000000
nhooyr.io/websocket v1.8.17
)
replace fn-registry => ../../..
+2
View File
@@ -0,0 +1,2 @@
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# E2E smoke against the running kanban (Vite dev :5180 with proxy → backend :8095).
#
# Verifies the latest version is actually being served:
# 1. /api/version returns the expected semver.
# 2. SPA HTML pulls fresh JS bundle.
# 3. JS bundle exposes notification/event endpoints (the headline feature
# of 0.2.0).
# 4. /api/notifications/unread-count rejects anonymous calls with 401 — the
# route is registered.
# 5. /api/events SSE endpoint returns 401 anonymous — registered.
# 6. /api/cards/<id>/chat/ws upgrade rejected without auth — registered.
#
# Exits non-zero on the first failure with a caveman explanation.
set -uo pipefail
BACKEND="${BACKEND:-http://127.0.0.1:8095}"
PROXY="${PROXY:-http://127.0.0.1:5180}"
EXPECTED_VERSION="${EXPECTED_VERSION:-0.3.0}"
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo "OK $*"; }
# 1. version
v=$(curl -sS -m 5 "$BACKEND/api/version" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
[[ "$v" == "$EXPECTED_VERSION" ]] || fail "backend version $v != $EXPECTED_VERSION"
ok "backend /api/version = $v"
vp=$(curl -sS -m 5 "$PROXY/api/version" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
[[ "$vp" == "$EXPECTED_VERSION" ]] || fail "proxy version $vp != $EXPECTED_VERSION"
ok "proxy /api/version = $vp"
# 2. SPA bundle hash visible in both
html_backend=$(curl -sS -m 5 "$BACKEND/" | tr -d '\n' | head -c 4096)
echo "$html_backend" | grep -qE '/assets/index-[A-Za-z0-9_-]+\.js' \
|| fail "backend /index.html does not reference an /assets/index-*.js"
ok "backend SPA references hashed bundle"
# 3. JS bundle contains the new feature endpoints
js_path=$(echo "$html_backend" | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js' | head -1)
[[ -n "$js_path" ]] || fail "could not extract JS asset path"
js_tmp=$(mktemp)
trap "rm -f $js_tmp" EXIT
curl -sS -m 10 -o "$js_tmp" "$BACKEND$js_path"
# Minifier mangles identifiers but preserves URL string literals. Probe a
# stable subset that maps 1:1 to the new feature.
for needle in "/notifications/unread-count" "/notifications/read-all" "/events" "/chat/ws"; do
grep -q "$needle" "$js_tmp" \
|| fail "bundle missing literal '$needle' (frontend not rebuilt?)"
done
ok "bundle ships notifications + SSE + WS client code"
# 4. /api/notifications/unread-count auth gate
code=$(curl -sS -o /dev/null -w '%{http_code}' -m 5 "$BACKEND/api/notifications/unread-count")
[[ "$code" == "401" ]] || fail "unread-count returned $code, want 401 (route missing?)"
ok "unread-count gated 401"
# 5. /api/events auth gate
code=$(curl -sS -o /dev/null -w '%{http_code}' -m 5 "$BACKEND/api/events")
[[ "$code" == "401" ]] || fail "/api/events returned $code, want 401"
ok "SSE /api/events gated 401"
# 6. /api/cards/{id}/chat/ws — upgrade fails without auth. We accept any
# 4xx/5xx as long as the path is recognized (a 404 would mean the route is
# not registered at all).
code=$(curl -sS -o /dev/null -w '%{http_code}' -m 5 \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGVzdA==' \
"$BACKEND/api/cards/__nope__/chat/ws")
[[ "$code" =~ ^(401|403|404)$ ]] || fail "card chat ws returned $code, want 401/403/404"
[[ "$code" != "404" ]] || ok "card chat ws path resolved ($code)"
ok "card chat WS route present (status $code)"
# 7. /api/modules — admin gated (401 unauthenticated).
code=$(curl -sS -o /dev/null -w '%{http_code}' -m 5 "$BACKEND/api/modules")
[[ "$code" == "401" ]] || fail "/api/modules returned $code, want 401"
ok "modules CRUD gated 401"
# 8. /api/modules/__nope__/test — exists (401 anonymous).
code=$(curl -sS -o /dev/null -w '%{http_code}' -m 5 -X POST "$BACKEND/api/modules/__nope__/test")
[[ "$code" == "401" ]] || fail "module test returned $code, want 401"
ok "modules test endpoint present"
# 9. bundle ships modules UI.
for needle in "/modules" "/modules/__draft__/test" "ModulesModal" "is_admin" "jira"; do
grep -q "$needle" "$js_tmp" && ok "bundle has '$needle'" || true
done
echo
echo "PASS — kanban $EXPECTED_VERSION serving notifications + streaming + modules UI"
+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
/**
* Issue 0092: cards en columnas DONE con >30 dias se mueven al cajon "Hecho".
* Test cubre: archivar via menu manual, listar archivo, des-archivar.
*/
test.describe("kanban archive (issue 0092)", () => {
test("archiva una done card via menu y la des-archiva desde el cajon", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Pick a card from a done column (queried directly from the API).
const board = await page.request.get("/api/board").then((r) => r.json());
const doneCol = (board.columns as Array<{ id: string; is_done: boolean }>).find((c) => c.is_done);
if (!doneCol) test.skip(true, "no done column in board");
const cardInDone = (board.cards as Array<{ id: string; column_id: string }>).find(
(c) => c.column_id === doneCol!.id
);
if (!cardInDone) test.skip(true, "no card in a done column");
const targetId = cardInDone!.id;
const cardSel = `[data-card-id="${targetId}"]`;
const card = page.locator(cardSel).first();
await expect(card).toBeVisible();
// Open the per-card menu. Use dispatchEvent so we ignore viewport scroll constraints.
await card.locator('button[aria-label="Acciones"]').dispatchEvent("click");
const archiveItem = page.getByRole("menuitem", { name: /Archivar/i }).first();
await expect(archiveItem).toBeVisible();
await archiveItem.click();
// Card disappears from board.
await expect(card).toHaveCount(0, { timeout: 5000 });
// Archive drawer toggle visible + opens.
const archiveToggle = page.locator('[data-test="archive-toggle"]');
await archiveToggle.scrollIntoViewIfNeeded();
await archiveToggle.dispatchEvent("click");
// Archived row appears in the drawer.
const archivedRow = page.locator(`[data-archived-card-id="${targetId}"]`);
await expect(archivedRow).toBeVisible({ timeout: 5000 });
// Restore from archive (force click — sidebar can be scrollable / off-viewport).
await archivedRow.locator("button").first().dispatchEvent("click");
// Back on board.
await expect(page.locator(cardSel).first()).toBeVisible({ timeout: 5000 });
// No longer in archive.
await expect(archivedRow).toHaveCount(0);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
/**
* Issue 0093: reporte diario al pulsar numero del dia en el calendario.
* Verifica: endpoint responde, calendario abre modal con titulo "Reporte diario",
* KPIs visibles, tabla de hechas presente.
*/
test.describe("daily report (issue 0093)", () => {
test("endpoint /api/reports/daily devuelve estructura esperada", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const today = new Date().toISOString().slice(0, 10);
const res = await page.request.get(`/api/reports/daily?date=${today}`);
expect(res.status()).toBe(200);
const data = await res.json();
expect(data).toHaveProperty("kpis");
expect(data).toHaveProperty("done_cards");
expect(data).toHaveProperty("hourly_moves");
expect(Array.isArray(data.hourly_moves)).toBe(true);
expect(data.hourly_moves.length).toBe(24);
expect(data).toHaveProperty("stale_cards");
expect(data.stale_cards).toHaveProperty("d7");
expect(data.stale_cards).toHaveProperty("d14");
expect(data.stale_cards).toHaveProperty("d30");
});
test("click en numero del dia del calendario abre modal del reporte", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Switch to Calendario tab.
await page.getByRole("tab", { name: /Calendario/i }).click();
// Wait until the calendar cells render.
await page.waitForSelector('[data-test^="calendar-day-"]', { timeout: 5000 });
// Use yesterday — the seeded DB has activity there.
const yesterday = new Date(Date.now() - 24 * 3600 * 1000).toISOString().slice(0, 10);
const cellBtn = page.locator(`[data-test="calendar-day-${yesterday}"]`);
if ((await cellBtn.count()) === 0) {
// Fallback: click any visible day.
await page.locator('[data-test^="calendar-day-"]').first().dispatchEvent("click");
} else {
await cellBtn.dispatchEvent("click");
}
// Modal opens.
const modal = page.locator('[role="dialog"]').filter({ hasText: /Reporte diario/i });
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal.getByText("Hechas", { exact: false }).first()).toBeVisible();
await expect(modal.getByText("Movimientos", { exact: false }).first()).toBeVisible();
});
});
+56
View File
@@ -0,0 +1,56 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
/**
* Issue 0094: bocadillo del agente + settings de prompt + PDF.
* No invocamos claude binario; testeamos endpoints settings y la UI estatica.
*/
test.describe("daily summary + pdf (issue 0094)", () => {
test("settings prompt CRUD roundtrip", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Lectura inicial: existe seed.
const initial = await page.request.get("/api/settings/daily_report_prompt").then((r) => r.json());
expect(initial.value).toContain("MAXIMO");
// Cambio.
const newVal = "test prompt " + Date.now();
const put = await page.request.put("/api/settings/daily_report_prompt", { data: { value: newVal } });
expect([200, 204]).toContain(put.status());
// Verifica.
const after = await page.request.get("/api/settings/daily_report_prompt").then((r) => r.json());
expect(after.value).toBe(newVal);
// Restaurar.
await page.request.put("/api/settings/daily_report_prompt", { data: { value: initial.value } });
});
test("daily summary GET vacio inicialmente, persiste si guardas manualmente", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const today = new Date().toISOString().slice(0, 10);
const before = await page.request.get(`/api/reports/daily/summary?date=${today}`).then((r) => r.json());
// Either exists=false OR exists=true with a string summary. Both valid.
expect(typeof before.exists).toBe("boolean");
expect(typeof before.summary === "string").toBe(true);
});
test("UI: bocadillo + boton PDF + boton settings visibles en modal", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
await page.getByRole("tab", { name: /Calendario/i }).click();
await page.waitForSelector('[data-test^="calendar-day-"]', { timeout: 5000 });
await page.locator('[data-test^="calendar-day-"]').first().dispatchEvent("click");
const modal = page.locator('[role="dialog"]').filter({ hasText: /Reporte diario/i });
await expect(modal).toBeVisible();
await expect(modal.locator('[data-test="daily-report-pdf"]')).toBeVisible();
await expect(modal.getByRole("button", { name: /Configurar prompt/i })).toBeVisible();
await expect(modal.getByRole("button", { name: /Regenerar|Generar/i }).first()).toBeVisible();
});
});
+142
View File
@@ -0,0 +1,142 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
/**
* Issue followup: drag lag.
* Capture per-frame durations via requestAnimationFrame while a card is dragged
* across reorder positions inside a populated column. Asserts p50 < 32ms and
* max < 120ms so a regression visibly slower than ~30 fps fails the suite.
*
* Read each measurement printed to console to track changes over time.
*/
test.describe("kanban drag perf", () => {
test("reorder inside HACIENDO does not drop below 30 fps", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Wait until at least one card is mounted.
await page.waitForSelector("[data-card-id]", { timeout: 10_000 });
// Inject a tiny frame-time recorder.
await page.evaluate(() => {
const w = window as unknown as {
_frames: number[];
_capturing: boolean;
_startCapture: () => void;
_stopCapture: () => number[];
};
w._frames = [];
w._capturing = false;
let prev = 0;
const tick = (t: number) => {
if (!w._capturing) return;
if (prev !== 0) w._frames.push(t - prev);
prev = t;
requestAnimationFrame(tick);
};
w._startCapture = () => {
w._frames = [];
w._capturing = true;
prev = 0;
requestAnimationFrame(tick);
};
w._stopCapture = () => {
w._capturing = false;
return w._frames.slice();
};
});
// Pick the column with the MOST cards (worst-case reorder cost).
const target = await page.evaluate(() => {
const cols = Array.from(document.querySelectorAll<HTMLElement>("[data-column-id]"));
let best: { columnId: string | null; cardIds: string[] } | null = null;
for (const col of cols) {
const cards = Array.from(col.querySelectorAll<HTMLElement>("[data-card-id]"));
if (!best || cards.length > best.cardIds.length) {
best = {
columnId: col.getAttribute("data-column-id"),
cardIds: cards
.map((c) => c.getAttribute("data-card-id"))
.filter((x): x is string => x !== null),
};
}
}
return best && best.cardIds.length >= 3 ? best : null;
});
if (!target) test.skip(true, "need a column with >= 3 cards");
const firstId = target!.cardIds[0]!;
const lastId = target!.cardIds[target!.cardIds.length - 1]!;
const source = page.locator(`[data-card-id="${firstId}"]`);
const targetEl = page.locator(`[data-card-id="${lastId}"]`);
const sb = await source.boundingBox();
const tb = await targetEl.boundingBox();
if (!sb || !tb) throw new Error("no bounding box");
const sx = sb.x + sb.width / 2;
const sy = sb.y + sb.height / 2;
const tx = tb.x + tb.width / 2;
const ty = tb.y + tb.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
// dnd-kit pointer-sensor activation threshold: 8px; nudge horizontally first.
await page.mouse.move(sx + 12, sy, { steps: 2 });
// Probe how many KanbanCard renders happen during the drag.
await page.evaluate(() => {
const w = window as unknown as { _cardRenderCount: number; _cardRenderProbe: boolean };
w._cardRenderCount = 0;
w._cardRenderProbe = true;
});
await page.evaluate(() => (window as unknown as { _startCapture: () => void })._startCapture());
// Move slowly across the column to trigger reorder swaps; steps=40 gives
// dnd-kit time to recompute positions.
await page.mouse.move(tx, ty, { steps: 40 });
// Hover so any final layout animation captures into the trace.
await page.waitForTimeout(120);
const frames = (await page.evaluate(() =>
(window as unknown as { _stopCapture: () => number[] })._stopCapture()
)) as number[];
const renderCount = (await page.evaluate(
() => (window as unknown as { _cardRenderCount: number })._cardRenderCount
)) as number;
const bodyCount = (await page.evaluate(
() => (window as unknown as { _cardBodyRenderCount: number })._cardBodyRenderCount || 0
)) as number;
console.log(`drag-perf wrapper-renders=${renderCount} body-renders=${bodyCount} over ${frames.length} frames`);
await page.mouse.up();
const sorted = [...frames].sort((a, b) => a - b);
const p50 = sorted[Math.floor(sorted.length * 0.5)] ?? 0;
const p95 = sorted[Math.floor(sorted.length * 0.95)] ?? 0;
const max = sorted.length > 0 ? sorted[sorted.length - 1] : 0;
const avg = sorted.reduce((a, b) => a + b, 0) / Math.max(1, sorted.length);
console.log(
`drag-perf frames=${sorted.length} avg=${avg.toFixed(1)}ms p50=${p50.toFixed(1)}ms p95=${p95.toFixed(1)}ms max=${max.toFixed(1)}ms`
);
// Save a stable artefact so we can compare runs.
test.info().annotations.push({
type: "drag-perf",
description: JSON.stringify({ count: sorted.length, avg, p50, p95, max }),
});
// Thresholds tuned tras separar el body memoizado (issue dnd-lag-fix
// followup). Pre-fix: p95=83ms / max=117ms. Post-fix: p95=33 / max=33.
expect(p50).toBeLessThan(20);
expect(p95).toBeLessThan(50);
expect(max).toBeLessThan(60);
// Body memoizado: durante el drag no debe re-renderizar.
expect(bodyCount).toBeLessThan(5);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
import { pw_keyboard_sequence } from "../../../../frontend/functions/browser/pw_keyboard_sequence";
import { pw_wait_predicate } from "../../../../frontend/functions/browser/pw_wait_predicate";
const USER = process.env.KANBAN_USER || "egutierrez";
const PWD = process.env.KANBAN_PWD || "egutierrez";
test.describe("Issue 0088 — requester input vacio + nav teclado", () => {
test("input solicitante entra vacio y ArrowDown+Enter no cierra modal", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Abrir Nueva tarjeta del primer "+" disponible en alguna columna del board.
const addBtn = page.locator('[data-test="add-card"]').first();
await addBtn.dispatchEvent("click");
// Modal de Mantine abierto.
const dialog = page.locator("[role=dialog]");
await expect(dialog).toBeVisible();
// Solicitante vacio.
const requester = dialog.locator('input[data-field="requester"]');
await expect(requester).toHaveValue("");
// Necesario titulo para que un eventual submit no se descarte por el guard.
await dialog.locator("textarea").first().fill("e2e test card");
// Tipear + navegar dropdown + Enter.
await requester.focus();
await pw_keyboard_sequence(page, [
{ kind: "type", text: "a", delayMs: 50 },
{ kind: "wait", ms: 300 },
{ kind: "press", key: "ArrowDown" },
{ kind: "press", key: "Enter" },
]);
// Modal sigue visible: Enter no ha cerrado el form.
await page.waitForTimeout(300);
await expect(dialog).toBeVisible();
// Cancelar para limpiar estado.
await dialog.locator("button:has-text('Cancelar')").click();
await expect(dialog).toBeHidden();
});
test("Enter en requester con dropdown cerrado NO cierra modal", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const addBtn = page.locator('[data-test="add-card"]').first();
await addBtn.dispatchEvent("click");
const dialog = page.locator("[role=dialog]");
await expect(dialog).toBeVisible();
await dialog.locator("textarea").first().fill("e2e test card 2");
const requester = dialog.locator('input[data-field="requester"]');
await requester.focus();
// Press Escape para asegurar dropdown cerrado, luego Enter.
await page.keyboard.press("Escape");
await page.keyboard.press("Enter");
await page.waitForTimeout(200);
await expect(dialog).toBeVisible();
await dialog.locator("button:has-text('Cancelar')").click();
await expect(dialog).toBeHidden();
});
});
+198
View File
@@ -0,0 +1,198 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
import { pw_drag_drop } from "../../../../frontend/functions/browser/pw_drag_drop";
import { pw_wait_predicate } from "../../../../frontend/functions/browser/pw_wait_predicate";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
interface BoardColumn {
id: string;
name: string;
location: "board" | "sidebar";
position: number;
}
interface BoardCard {
id: string;
column_id: string;
title: string;
}
interface BoardResponse {
columns: BoardColumn[];
cards: BoardCard[];
}
test.describe("Issue 0091 — sidebar drag dropzone", () => {
test("drag near left edge opens sidebar and drop moves card to sidebar column", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Pre-req: ensure there is at least one sidebar column and a card on the board.
const initialBoard: BoardResponse = await page.request
.get("/api/board")
.then((r) => r.json());
let sidebarCol = initialBoard.columns.find((c) => c.location === "sidebar");
if (!sidebarCol) {
const created = await page.request
.post("/api/columns", {
data: { name: "E2E Sidebar", location: "sidebar" },
})
.then((r) => r.json());
sidebarCol = created as BoardColumn;
}
const boardCol = initialBoard.columns.find((c) => c.location !== "sidebar");
if (!boardCol) {
test.skip(true, "no board column to drag a card from");
return;
}
// Ensure at least one card exists in a board column we can drag.
let card = initialBoard.cards.find((c) => c.column_id === boardCol.id);
if (!card) {
const created = await page.request
.post("/api/cards", {
data: {
column_id: boardCol.id,
title: `e2e dropzone card ${Date.now()}`,
requester: "e2e",
},
})
.then((r) => r.json());
card = created as BoardCard;
// Reload UI so the new card appears.
await page.reload();
await page.waitForLoadState("networkidle");
}
// Sanity: side bar should start closed. The toggle button has aria-label="Toggle sidebar".
const toggleBtn = page.locator('button[aria-label="Toggle sidebar"]');
await expect(toggleBtn).toBeVisible();
// The Mantine Navbar has a known data attribute (data-mantine-component=AppShellNavbar)
// but the simplest check is: when collapsed, the desktop navbar is hidden via display:none.
// We use the strip element's visibility too.
const strip = page.locator('[data-test="kanban-drag-edge"]');
await expect(strip).toHaveCount(1);
// While not dragging, strip is_active=0.
await expect(strip).toHaveAttribute("data-active", "0");
const cardLocator = page.locator(`[data-card-id="${card!.id}"]`);
await expect(cardLocator).toBeVisible();
// Build a "left edge" target by creating a 1x100 box near x=10 to drop on.
// pw_drag_drop expects a Locator for the target; we use the strip itself
// even though pointer-events:none — page.mouse.move works against the
// viewport so its bounding box only drives where the pointer goes.
// We override hoverMs=700 so the 400ms timer fires well within the hover.
// Get the card bounding box.
const cardBox = await cardLocator.boundingBox();
if (!cardBox) throw new Error("card has no bounding box");
// Manually drive the pointer: press down on card, drag to x=10, dwell 700ms,
// assert sidebar opened (via predicate on toggle button aria-pressed OR the
// strip's data-active attribute observed), then drop on sidebar column.
const sx = cardBox.x + cardBox.width / 2;
const sy = cardBox.y + cardBox.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
// Cross dnd-kit's 5px activation threshold (we configured PointerSensor distance:5).
await page.mouse.move(sx + 15, sy, { steps: 4 });
// Glide towards x=10 (inside the 32px strip).
const edgeX = 10;
const edgeY = sy; // keep vertical, change horizontal.
const steps = 25;
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const xi = (sx + 15) + (edgeX - (sx + 15)) * t;
const yi = sy + (edgeY - sy) * t;
await page.mouse.move(xi, yi);
await page.waitForTimeout(16);
}
// Now dwell inside the strip — the 400ms timer should fire.
// While dwelling, every ~50ms we nudge the mouse 1px to keep dnd-kit pointer events alive
// but stay inside the strip.
const dwellMs = 700;
const nudgeStart = Date.now();
while (Date.now() - nudgeStart < dwellMs) {
await page.mouse.move(edgeX + ((Date.now() / 50) % 2), edgeY);
await page.waitForTimeout(50);
}
// Assert: the strip is now armed AND the sidebar opened.
await expect(strip).toHaveAttribute("data-armed", "1");
// Wait for sidebar column header text to appear (sidebar opened).
await pw_wait_predicate(
page,
(sidebarName: string) => {
const els = Array.from(document.querySelectorAll('[data-column-location="sidebar"]'));
// Element must be visible (offsetParent != null is a good proxy for display!=none).
return els.some((el) => (el as HTMLElement).offsetParent !== null);
},
{
arg: sidebarCol!.name,
timeoutMs: 3000,
pollMs: 100,
message: "sidebar column did not become visible after dwell",
}
);
// Now move pointer to the sidebar column and release.
const sidebarColLoc = page.locator(`[data-column-id="${sidebarCol!.id}"]`).first();
await expect(sidebarColLoc).toBeVisible();
const sbBox = await sidebarColLoc.boundingBox();
if (!sbBox) throw new Error("sidebar column has no bounding box");
const tx = sbBox.x + sbBox.width / 2;
const ty = sbBox.y + sbBox.height / 2;
const dropSteps = 15;
let lastX = edgeX;
let lastY = edgeY;
for (let i = 1; i <= dropSteps; i++) {
const t = i / dropSteps;
const xi = lastX + (tx - lastX) * t;
const yi = lastY + (ty - lastY) * t;
await page.mouse.move(xi, yi);
await page.waitForTimeout(20);
}
await page.waitForTimeout(150);
await page.mouse.up();
// Validate via API the card moved to the sidebar column.
await pw_wait_predicate(
page,
async (args: { id: string; col: string }) => {
const res = await fetch("/api/board", { credentials: "same-origin" });
const b = await res.json();
const c = (b.cards as BoardCard[]).find((x) => x.id === args.id);
return c?.column_id === args.col;
},
{
arg: { id: card!.id, col: sidebarCol!.id },
timeoutMs: 5000,
pollMs: 200,
message: "card did not land in sidebar column after drop",
}
);
});
test("strip stays inactive when there is no drag", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const strip = page.locator('[data-test="kanban-drag-edge"]');
await expect(strip).toHaveCount(1);
await expect(strip).toHaveAttribute("data-active", "0");
await expect(strip).toHaveAttribute("data-armed", "0");
// Move the pointer over the left edge — without a drag, strip must stay disarmed.
await page.mouse.move(10, 200);
await page.waitForTimeout(600);
await expect(strip).toHaveAttribute("data-armed", "0");
});
});
+11 -2
View File
@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -30,12 +32,19 @@
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/ui": "^4.1.6",
"jsdom": "^29.1.1",
"postcss": "^8.5.4",
"postcss-preset-mantine": "^1.17.0",
"typescript": "~5.8.3",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vitest": "^4.1.6"
}
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
retries: 0,
workers: 1,
reporter: [["list"]],
use: {
baseURL: process.env.KANBAN_BASE_URL || "http://localhost:5180",
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "off",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+841
View File
File diff suppressed because it is too large Load Diff
+468 -19
View File
@@ -50,10 +50,14 @@ import {
IconArrowBackUp,
IconCalendar,
IconChartBar,
IconCheck,
IconChevronDown,
IconChevronRight,
IconLayoutKanban,
IconLogout,
IconPlug,
IconKey,
IconBrandJira,
IconMenu2,
IconMessageChatbot,
IconMoodSmile,
@@ -68,8 +72,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as api from "./api";
import { useAuth } from "./auth";
import { CardForm } from "./components/CardForm";
import { CardEditPanel } from "./components/CardEditPanel";
import { ChatPanel } from "./components/ChatPanel";
import { CalendarView } from "./components/CalendarView";
import { DailyReportView } from "./components/DailyReport";
import { Dashboard } from "./components/Dashboard";
import { HistoryModal } from "./components/HistoryModal";
import { KanbanCard } from "./components/KanbanCard";
@@ -78,7 +84,12 @@ import { StickerPicker } from "./components/StickerPicker";
import { ColorPickerGrid, CustomColorModal } from "./components/ColorPickerGrid";
import { AVATAR_COLORS } from "./components/colors";
import { colorBg, colorBorder } from "./components/colors";
import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types";
import { NotificationsBell } from "./components/NotificationsBell";
import { ModulesModal } from "./components/ModulesModal";
import { MCPTokensModal } from "./components/MCPTokensModal";
import { JiraModal } from "./components/JiraModal";
import { useEventStream } from "./hooks/useEventStream";
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
const COL_PREFIX = "column-";
@@ -117,6 +128,8 @@ export function App() {
const [activeTab, setActiveTab] = useState<string>("board");
const [trash, setTrash] = useState<Card[]>([]);
const [trashOpen, setTrashOpen] = useState(false);
const [archive, setArchive] = useState<Card[]>([]);
const [archiveOpen, setArchiveOpen] = useState(false);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
@@ -171,6 +184,72 @@ export function App() {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
// -------- Issue 0091 — drag-aware sidebar dropzone --------
// While a card or column is being dragged, watch the global pointer.
// If it dwells inside the 32px left strip for >=400ms, auto-open the sidebar.
// We listen to mousemove globally because dnd-kit owns the pointer during
// drag, and the strip itself has pointer-events:none so dnd-kit keeps
// detecting drop targets underneath.
const DRAG_EDGE_WIDTH = 32;
const DRAG_EDGE_HOVER_MS = 400;
const isDragging = activeCard !== null || activeColumnId !== null;
const [edgeArmed, setEdgeArmed] = useState(false);
const navOpenRef = useRef(navOpen);
useEffect(() => {
navOpenRef.current = navOpen;
}, [navOpen]);
useEffect(() => {
if (!isDragging) {
setEdgeArmed(false);
return;
}
let timer: number | null = null;
let inside = false;
// Para evitar que un drag iniciado dentro del sidebar abierto dispare un
// cierre inmediato, exigimos que el puntero haya salido de la franja al
// menos una vez tras empezar el drag. Asi: abrir = entrar a la franja
// tras empezar fuera (que ya pasaba); cerrar = salir de la franja y
// volver a entrar.
let hasLeftStrip = false;
const clear = () => {
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
};
const onMove = (ev: MouseEvent) => {
const nowInside = ev.clientX <= DRAG_EDGE_WIDTH;
if (nowInside === inside) return;
inside = nowInside;
// Brillo visible siempre que el puntero este en la franja y haya drag.
setEdgeArmed(nowInside);
if (!nowInside) {
hasLeftStrip = true;
clear();
return;
}
// nowInside = true. Para cerrar (navOpen=true) exigimos que el puntero
// haya salido al menos una vez de la franja desde que empezo el drag;
// asi un drag que arranca dentro del sidebar abierto no auto-cierra.
const armable = !navOpenRef.current || hasLeftStrip;
if (!armable) return;
clear();
const willOpen = !navOpenRef.current;
timer = window.setTimeout(() => {
setNavOpen(willOpen);
// Tras toggle, resetea el flag para no encadenar otra accion sin
// que el usuario salga + vuelva.
hasLeftStrip = false;
}, DRAG_EDGE_HOVER_MS);
};
document.addEventListener("mousemove", onMove);
return () => {
document.removeEventListener("mousemove", onMove);
clear();
setEdgeArmed(false);
};
}, [isDragging]);
const reload = useCallback(async () => {
try {
const b = await api.getBoard();
@@ -180,6 +259,23 @@ export function App() {
}
}, []);
// Coalesce ráfagas de board.invalidated (trailing debounce 300ms) — sin esto
// cada mutación remota dispara un refetch /api/board completo y la memoria
// del navegador crece sin techo.
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedReload = useCallback(() => {
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = setTimeout(() => {
reloadTimerRef.current = null;
reload();
}, 300);
}, [reload]);
useEffect(() => {
return () => {
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
};
}, []);
useEffect(() => {
reload();
}, [reload]);
@@ -202,6 +298,15 @@ export function App() {
}
}, []);
const reloadArchive = useCallback(async () => {
try {
const a = await api.listArchive();
setArchive(a);
} catch (e) {
console.warn("listArchive failed", e);
}
}, []);
const reloadTags = useCallback(async () => {
try {
const t = await api.listTags();
@@ -228,16 +333,95 @@ export function App() {
reloadTrash();
}, [reloadTrash]);
useEffect(() => {
reloadArchive();
}, [reloadArchive]);
useEffect(() => {
reloadTags();
reloadRequesters();
}, [reloadTags, reloadRequesters]);
// Tick de reloj para "tiempo en columna" en cards. Pausamos durante drag
// porque dispara re-render de TODAS las cards cada segundo y el drag de
// dnd-kit sufre tirones serios con muchos elementos.
useEffect(() => {
if (activeCard || activeColumnId) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [activeCard, activeColumnId]);
// Notifications state (populated by SSE + initial fetch).
const [notifs, setNotifs] = useState<Notification[]>([]);
const [notifUnread, setNotifUnread] = useState(0);
// Build version (injected at compile time via -ldflags). Fetched once.
const [appVersion, setAppVersion] = useState<string>("");
useEffect(() => {
api
.getVersion()
.then((v) => setAppVersion(v.version))
.catch(() => setAppVersion(""));
}, []);
const [modulesOpen, setModulesOpen] = useState(false);
const [mcpTokensOpen, setMcpTokensOpen] = useState(false);
const [jiraImportOpen, setJiraImportOpen] = useState(false);
const reloadNotifs = useCallback(async () => {
try {
const [list, c] = await Promise.all([api.listNotifications(false), api.unreadNotificationCount()]);
setNotifs(list);
setNotifUnread(c.count);
} catch {
// best-effort; SSE will reconcile
}
}, []);
useEffect(() => {
if (auth.user) reloadNotifs();
}, [auth.user, reloadNotifs]);
// Replace 30s polling with SSE. Server pushes board.invalidated on every
// mutation, message.created on chat traffic and notification.created on
// per-user notifications. We refetch /api/board on invalidate (cheap +
// keeps merge logic simple) and patch notification state in-place.
useEventStream(
useMemo(
() => ({
"board.invalidated": () => {
debouncedReload();
},
"notification.created": (payload: unknown) => {
const n = payload as Notification;
if (!n || !n.id) return;
setNotifs((prev) => (prev.some((x) => x.id === n.id) ? prev : [n, ...prev].slice(0, 100)));
setNotifUnread((c) => c + 1);
const who = n.actor_name || "Alguien";
const card = n.card_seq_num ? `#${n.card_seq_num}` : n.card_title;
notifications.show({
autoClose: 4000,
color: n.kind === "mention" ? "grape" : "blue",
title: `${who} en ${card}`,
message: n.snippet,
});
},
"notification.read": (payload: unknown) => {
const p = payload as { id?: string } | null;
if (!p?.id) return;
setNotifs((prev) => prev.map((x) => (x.id === p.id ? { ...x, read_at: new Date().toISOString() } : x)));
setNotifUnread((c) => Math.max(0, c - 1));
},
"notification.read_all": () => {
setNotifs((prev) => prev.map((x) => (x.read_at ? x : { ...x, read_at: new Date().toISOString() })));
setNotifUnread(0);
},
}),
[debouncedReload],
),
!!auth.user,
);
useEffect(() => {
if (!activeSticker) return;
const onKey = (e: KeyboardEvent) => {
@@ -268,16 +452,21 @@ export function App() {
(c: Card): boolean => {
const term = searchTerm.trim().toLowerCase();
if (term) {
const seqStr = c.seq_num > 0 ? String(c.seq_num) : "";
const seqPadded = c.seq_num > 0 ? String(c.seq_num).padStart(5, "0") : "";
const hay = [
c.title,
c.description,
c.requester,
seqStr,
seqPadded,
...(c.tags || []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!hay.includes(term)) return false;
const normalizedTerm = term.replace(/^#/, "").replace(/^0+(?=\d)/, "");
if (!hay.includes(term) && !(normalizedTerm && hay.includes(normalizedTerm))) return false;
}
if (filterAssigneeId && c.assignee_id !== filterAssigneeId) return false;
if (filterUnassigned && c.assignee_id) return false;
@@ -463,6 +652,10 @@ export function App() {
try {
await api.moveCard(activeId, destCol, orderedIds);
// Nudge the moved card's Jira sync indicator to refetch immediately
// so the operator sees the yellow "syncing" state without waiting for
// the steady-state poll tick (5s).
window.dispatchEvent(new CustomEvent("kanban-card-moved", { detail: { cardId: activeId } }));
} catch (err) {
notifications.show({ color: "red", message: (err as Error).message });
}
@@ -537,7 +730,7 @@ export function App() {
users={users}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
initial={{ requester: "" }}
submitLabel="Crear"
onCancel={() => modals.close(id)}
onSubmit={async (v) => {
@@ -563,23 +756,18 @@ export function App() {
});
}, [reload, users, auth.user, requesterOptions, tagOptions]);
const openEditCard = useCallback((card: Card) => {
const openEditCard = useCallback((card: Card, options?: { highlightMessageId?: string }) => {
const id = modals.open({
title: "Editar tarjeta",
size: "md",
size: "85%",
children: (
<CardForm
<CardEditPanel
card={card}
users={users}
currentUserId={auth.user?.id}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
initial={{
requester: card.requester,
title: card.title,
description: card.description,
assignee_id: card.assignee_id,
tags: card.tags || [],
}}
submitLabel="Guardar"
highlightMessageId={options?.highlightMessageId}
onCancel={() => modals.close(id)}
onSubmit={async (v) => {
try {
@@ -601,7 +789,17 @@ export function App() {
/>
),
});
}, [reload, users, requesterOptions, tagOptions]);
}, [reload, users, auth.user, requesterOptions, tagOptions]);
const handleDuplicateCard = useCallback(async (cardId: string) => {
try {
const dup = await api.duplicateCard(cardId);
await reload();
notifications.show({ color: "teal", message: `Duplicada: ${dup.title}` });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload]);
const handleSetRequester = useCallback(async (id: string, requester: string) => {
setBoard((prev) => {
@@ -622,6 +820,22 @@ export function App() {
window.setTimeout(() => setHighlightCardId(null), 3000);
}, []);
const handleOpenDailyReport = useCallback((date: string) => {
const id = modals.open({
title: "Reporte diario",
size: "90%",
children: (
<DailyReportView
date={date}
onJumpToCard={(cardId) => {
modals.close(id);
handleJumpToCard(cardId);
}}
/>
),
});
}, [handleJumpToCard]);
const handleSetCardDeadline = useCallback(async (id: string, deadline: string | null) => {
setBoard((prev) => {
if (!prev) return prev;
@@ -668,6 +882,26 @@ export function App() {
}
}, [reload, reloadTrash]);
const handleUnarchiveCard = useCallback(async (id: string) => {
try {
await api.unarchiveCard(id);
reload();
reloadArchive();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload, reloadArchive]);
const handleArchiveCard = useCallback(async (id: string) => {
try {
await api.archiveCard(id);
reload();
reloadArchive();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload, reloadArchive]);
const handlePurgeCard = useCallback(async (id: string) => {
modals.openConfirmModal({
title: "Borrar permanentemente",
@@ -769,9 +1003,9 @@ export function App() {
modals.open({
title: card.title,
size: "md",
children: <HistoryModal card={card} />,
children: <HistoryModal card={card} columns={board?.columns ?? []} />,
});
}, []);
}, [board?.columns]);
const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => {
setBoard((prev) => {
@@ -799,6 +1033,81 @@ export function App() {
}
}, [reload]);
// Issue 0090: ruleta de seleccion aleatoria por columna.
// Recorre las cards visibles (post-filtro) no bloqueadas con highlight
// acelerado-decelerado y termina con flash verde sobre la ganadora.
const handlePickRandom = useCallback((columnId: string) => {
const cards = (cardsByColumn.get(columnId) || []).filter((c) => !c.locked);
if (cards.length === 0) {
notifications.show({ color: "yellow", message: "No hay cards disponibles (filtro y bloqueadas excluidas)" });
return;
}
if (cards.length === 1) {
const el = document.querySelector<HTMLElement>(`[data-card-id="${cards[0].id}"]`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("kanban-roulette-winner");
setTimeout(() => el.classList.remove("kanban-roulette-winner"), 1700);
}
return;
}
// Decide ganadora con seguridad criptografica.
const winnerIdx = (() => {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0] % cards.length;
})();
// Total steps: minimo 2 vueltas completas + offset hasta la ganadora.
const baseLaps = 2;
const totalSteps = baseLaps * cards.length + ((winnerIdx - 0 + cards.length) % cards.length);
// Decay temporal: empieza rapido (50ms), termina lento (220ms).
const startMs = 50;
const endMs = 220;
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
let step = 0;
const tick = () => {
const idx = step % cards.length;
const prevIdx = (idx - 1 + cards.length) % cards.length;
const prevEl = document.querySelector<HTMLElement>(`[data-card-id="${cards[prevIdx].id}"]`);
const currEl = document.querySelector<HTMLElement>(`[data-card-id="${cards[idx].id}"]`);
if (prevEl) prevEl.classList.remove("kanban-roulette-active");
if (currEl) {
currEl.classList.add("kanban-roulette-active");
currEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
step++;
if (step > totalSteps) {
if (currEl) {
currEl.classList.remove("kanban-roulette-active");
currEl.classList.add("kanban-roulette-winner");
setTimeout(() => currEl.classList.remove("kanban-roulette-winner"), 1700);
}
return;
}
const t = totalSteps > 0 ? step / totalSteps : 1;
const delay = startMs + (endMs - startMs) * easeOut(t);
setTimeout(tick, delay);
};
tick();
}, [cardsByColumn]);
const handleSetMaxTimeMinutes = useCallback(async (id: string, max_time_minutes: number) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, max_time_minutes } : c)) };
});
try {
await api.updateColumn(id, { max_time_minutes });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleToggleDone = useCallback(async (id: string, is_done: boolean) => {
setBoard((prev) => {
if (!prev) return prev;
@@ -854,6 +1163,18 @@ export function App() {
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
{/* Issue 0091 — drag-aware left edge strip; opens sidebar on hover>=400ms */}
<div
className={
"kanban-drag-edge" +
(isDragging ? " is-active" : "") +
(edgeArmed ? " is-armed" : "")
}
data-test="kanban-drag-edge"
data-active={isDragging ? "1" : "0"}
data-armed={edgeArmed ? "1" : "0"}
aria-hidden="true"
/>
<AppShell
header={headerConfig}
navbar={navbarConfig}
@@ -891,6 +1212,38 @@ export function App() {
<ActionIcon variant="subtle" onClick={reload} aria-label="Refresh">
<IconRefresh size={16} />
</ActionIcon>
{auth.user && (
<NotificationsBell
unreadCount={notifUnread}
notifications={notifs}
onOpenCard={async (cardId, messageId) => {
// Resolve the card across all possible buckets: live
// board, refreshed board, archive, trash. Notifications
// can point at any of them.
const find = (cs?: Card[]) => cs?.find((c) => c.id === cardId);
let card = find(board?.cards);
if (!card) {
await reload();
const fresh = await api.getBoard();
card = find(fresh.cards);
}
if (!card) {
const archived = await api.listArchive();
card = find(archived);
}
if (!card) {
const trashed = await api.listTrash();
card = find(trashed);
}
if (!card) {
notifications.show({ color: "red", message: "Card no encontrada" });
return;
}
openEditCard(card, { highlightMessageId: messageId });
}}
onChanged={reloadNotifs}
/>
)}
<ActionIcon
variant={chatOpen ? "filled" : "subtle"}
onClick={() => setChatOpen((v) => !v)}
@@ -908,7 +1261,16 @@ export function App() {
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{auth.user.display_name || auth.user.username}</Menu.Label>
<Menu.Label>
<Group justify="space-between" gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{auth.user.display_name || auth.user.username}
</Text>
{appVersion && (
<Text size="xs" c="dimmed" ff="monospace">v{appVersion}</Text>
)}
</Group>
</Menu.Label>
<Box p="xs">
<Text size="xs" c="dimmed" mb={4}>Color del avatar</Text>
<ColorPickerGrid
@@ -929,6 +1291,28 @@ export function App() {
/>
</Box>
<Menu.Divider />
{auth.user.is_admin && (
<Menu.Item
leftSection={<IconPlug size={14} />}
onClick={() => setModulesOpen(true)}
>
Modulos
</Menu.Item>
)}
{auth.user.is_admin && (
<Menu.Item
leftSection={<IconBrandJira size={14} />}
onClick={() => setJiraImportOpen(true)}
>
Jira
</Menu.Item>
)}
<Menu.Item
leftSection={<IconKey size={14} />}
onClick={() => setMcpTokensOpen(true)}
>
MCP tokens
</Menu.Item>
<Menu.Item
leftSection={<IconLogout size={14} />}
color="red"
@@ -939,6 +1323,18 @@ export function App() {
</Menu.Dropdown>
</Menu>
)}
{auth.user?.is_admin && (
<ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
)}
{auth.user?.is_admin && board && (
<JiraModal
opened={jiraImportOpen}
onClose={() => setJiraImportOpen(false)}
columns={board.columns}
onMutated={() => reload()}
/>
)}
<MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
</Group>
</Group>
</AppShell.Header>
@@ -983,9 +1379,12 @@ export function App() {
onMoveColumnLocation={handleMoveColumnLocation}
onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
onPickRandom={handlePickRandom}
onToggleDone={handleToggleDone}
onEditCard={openEditCard}
onDeleteCard={handleDeleteCard}
onDuplicateCard={handleDuplicateCard}
onChangeCardColor={handleChangeCardColor}
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
@@ -993,6 +1392,7 @@ export function App() {
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
onArchiveCard={handleArchiveCard}
requesterOptions={requesterOptions}
onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
activeSticker={activeSticker}
@@ -1056,6 +1456,51 @@ export function App() {
</Stack>
)}
</Box>
<Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
<Button
variant="subtle"
color="gray"
size="xs"
fullWidth
justify="space-between"
leftSection={<IconCheck size={14} />}
rightSection={
<Group gap={4}>
<Badge size="xs" variant="light" color={archive.length > 0 ? "teal" : "gray"}>
{archive.length}
</Badge>
{archiveOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
</Group>
}
onClick={() => setArchiveOpen((v) => !v)}
data-test="archive-toggle"
>
Hecho (archivo)
</Button>
{archiveOpen && (
<Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
{archive.length === 0 && (
<Text size="xs" c="dimmed" px="xs">
Sin cards archivadas.
</Text>
)}
{archive.map((c) => (
<Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7" data-archived-card-id={c.id}>
<Group justify="space-between" gap={4} wrap="nowrap">
<Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
{c.title}
</Text>
<Tooltip label="Sacar del archivo (volver a Hecho)" withArrow>
<ActionIcon size="xs" variant="subtle" color="teal" onClick={() => handleUnarchiveCard(c.id)}>
<IconArrowBackUp size={12} />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
))}
</Stack>
)}
</Box>
</Stack>
</AppShell.Navbar>
@@ -1070,7 +1515,7 @@ export function App() {
</Box>
) : activeTab === "calendar" ? (
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} />
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} onOpenDailyReport={handleOpenDailyReport} />
</Box>
) : (
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
@@ -1250,9 +1695,12 @@ export function App() {
onMoveColumnLocation={handleMoveColumnLocation}
onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
onPickRandom={handlePickRandom}
onToggleDone={handleToggleDone}
onEditCard={openEditCard}
onDeleteCard={handleDeleteCard}
onDuplicateCard={handleDuplicateCard}
onChangeCardColor={handleChangeCardColor}
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
@@ -1260,6 +1708,7 @@ export function App() {
onSetCardDeadline={handleSetCardDeadline}
highlightCardId={highlightCardId}
onSetRequester={handleSetRequester}
onArchiveCard={handleArchiveCard}
requesterOptions={requesterOptions}
activeSticker={activeSticker}
onAddSticker={handleAddSticker}
+442 -7
View File
@@ -1,10 +1,16 @@
import type {
Board,
Card,
CardFile,
CardHistoryResponse,
CardMessage,
Column,
KanbanModule,
Metrics,
MetricsFilter,
ModuleLog,
ModuleTestResult,
Notification,
Sticker,
User,
} from "./types";
@@ -22,6 +28,14 @@ export function getBoard(): Promise<Board> {
return fetchJSON("/board");
}
export function getFlags(): Promise<Record<string, boolean>> {
return fetchJSON("/flags");
}
export function getVersion(): Promise<{ version: string }> {
return fetchJSON("/version");
}
export function createColumn(name: string): Promise<Column> {
return fetchJSON("/columns", { method: "POST", body: JSON.stringify({ name }) });
}
@@ -33,6 +47,7 @@ export interface UpdateColumnInput {
width?: number;
wip_limit?: number;
is_done?: boolean;
max_time_minutes?: number;
}
export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> {
@@ -101,6 +116,133 @@ export function purgeCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/purge`, { method: "DELETE" });
}
export function listArchive(): Promise<Card[]> {
return fetchJSON("/archive");
}
export function archiveCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/archive`, { method: "POST" });
}
export function unarchiveCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/unarchive`, { method: "POST" });
}
export interface DailyReport {
date: string;
tz: string;
start_ts: string;
end_ts: string;
kpis: {
done: number;
created: number;
moves: number;
blocked_ms: number;
deadlines_met: number;
deadlines_missed: number;
reopened: number;
archived_auto: number;
archived_manual: number;
};
top_assignees_done: { user_id: string; name: string; count: number }[];
top_assignees_created: { user_id: string; name: string; count: number }[];
top_requesters_added: { name: string; count: number }[];
top_requesters_done: { name: string; count: number }[];
done_cards: {
id: string;
seq_num: number;
title: string;
requester: string;
assignee_id: string | null;
assignee_name: string | null;
tags: string[];
column_id: string;
column_name: string;
completed_at: string;
created_at: string;
lead_time_ms: number;
color: string;
}[];
reopened_cards: {
card_id: string;
title: string;
seq_num: number;
from_column: string;
to_column: string;
ts: string;
actor_id: string | null;
actor_name: string | null;
}[];
stale_cards: {
d7: StaleEntry[];
d14: StaleEntry[];
d30: StaleEntry[];
};
lead_time: { avg_ms: number; p50_ms: number; p95_ms: number; samples: number };
hourly_moves: number[];
deadlines: {
met: number;
missed: number;
list: {
card_id: string;
title: string;
seq_num: number;
deadline: string;
completed_at: string;
late_ms: number;
}[];
};
tags_done: { name: string; count: number }[];
archived_today: number;
}
export interface StaleEntry {
card_id: string;
title: string;
seq_num: number;
column_id: string;
column_name: string;
entered_at: string;
days: number;
}
export function dailyReport(date: string, tz?: string): Promise<DailyReport> {
const params = new URLSearchParams({ date });
if (tz) params.set("tz", tz);
return fetchJSON(`/reports/daily?${params.toString()}`);
}
export interface DailySummary {
date: string;
summary: string;
prompt?: string;
model?: string;
generated_at?: string;
generated_by?: string | null;
exists: boolean;
}
export function getDailySummary(date: string): Promise<DailySummary> {
return fetchJSON(`/reports/daily/summary?date=${encodeURIComponent(date)}`);
}
export function generateDailySummary(date: string, tz?: string): Promise<DailySummary> {
const params = new URLSearchParams({ date });
if (tz) params.set("tz", tz);
return fetchJSON(`/reports/daily/summary?${params.toString()}`, { method: "POST" });
}
export function getSetting(key: string): Promise<{ key: string; value: string }> {
return fetchJSON(`/settings/${encodeURIComponent(key)}`);
}
export function setSetting(key: string, value: string): Promise<void> {
return fetchJSON(`/settings/${encodeURIComponent(key)}`, {
method: "PUT",
body: JSON.stringify({ value }),
});
}
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
return fetchJSON(`/cards/${id}/move`, {
method: "POST",
@@ -112,6 +254,25 @@ export function cardHistory(id: string): Promise<CardHistoryResponse> {
return fetchJSON(`/cards/${id}/history`);
}
export function listCardMessages(id: string): Promise<CardMessage[]> {
return fetchJSON(`/cards/${id}/messages`);
}
export function createCardMessage(id: string, body: string): Promise<CardMessage> {
return fetchJSON(`/cards/${id}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
}
export function deleteCardMessage(cardId: string, messageId: string): Promise<void> {
return fetchJSON(`/cards/${cardId}/messages/${messageId}`, { method: "DELETE" });
}
export function duplicateCard(id: string): Promise<Card> {
return fetchJSON(`/cards/${id}/duplicate`, { method: "POST" });
}
export interface ChatMessage {
role: "user" | "assistant";
content: string;
@@ -121,17 +282,128 @@ export interface ChatToolCall {
tool: string;
ok: boolean;
error?: string;
input?: unknown;
}
export interface ChatResponse {
role: "assistant";
content: string;
board_changed: boolean;
tool_calls?: ChatToolCall[];
// WebSocket streaming events emitted by /api/chat/ws.
export type ChatStreamEvent =
| { type: "delta"; text: string }
| { type: "tool_use"; tool_id: string; tool: string; input?: unknown }
| { type: "tool_result"; tool_id: string; result?: string; is_error?: boolean }
| { type: "result"; text?: string; is_error?: boolean }
| { type: "done"; board_changed?: boolean }
| { type: "error"; error: string };
// chatWSURL builds the absolute ws:// or wss:// URL of the streaming endpoint.
export function chatWSURL(): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/api/chat/ws`;
}
export function sendChat(messages: ChatMessage[]): Promise<ChatResponse> {
return fetchJSON("/chat", { method: "POST", body: JSON.stringify({ messages }) });
export function cardChatWSURL(cardId: string): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/api/cards/${cardId}/chat/ws`;
}
export function listNotifications(unreadOnly = false): Promise<Notification[]> {
return fetchJSON(`/notifications${unreadOnly ? "?unread=1" : ""}`);
}
export function unreadNotificationCount(): Promise<{ count: number }> {
return fetchJSON("/notifications/unread-count");
}
export function markNotificationRead(id: string): Promise<void> {
return fetchJSON(`/notifications/${id}/read`, { method: "POST" });
}
export function markAllNotificationsRead(): Promise<{ count: number }> {
return fetchJSON("/notifications/read-all", { method: "POST" });
}
export function listModules(): Promise<KanbanModule[]> {
return fetchJSON("/modules");
}
export interface ModuleInput {
name: string;
kind: string;
enabled: boolean;
event_filter: string[];
config: Record<string, unknown>;
}
export function createModule(body: ModuleInput): Promise<KanbanModule> {
return fetchJSON("/modules", { method: "POST", body: JSON.stringify(body) });
}
export function updateModule(id: string, patch: Partial<ModuleInput>): Promise<KanbanModule> {
return fetchJSON(`/modules/${id}`, { method: "PATCH", body: JSON.stringify(patch) });
}
export function deleteModule(id: string): Promise<void> {
return fetchJSON(`/modules/${id}`, { method: "DELETE" });
}
export function listModuleLogs(id: string, limit = 100): Promise<ModuleLog[]> {
return fetchJSON(`/modules/${id}/logs?limit=${limit}`);
}
export function testModule(idOrDraft: string, body?: ModuleInput): Promise<ModuleTestResult> {
const init: RequestInit = { method: "POST" };
if (body) init.body = JSON.stringify(body);
return fetchJSON(`/modules/${idOrDraft}/test`, init);
}
// streamChat opens a WebSocket, sends the message history, and streams events
// to onEvent. Returns a Promise that resolves when the server closes the
// connection (after a "done" event) and rejects on transport errors.
export function streamChat(
messages: ChatMessage[],
onEvent: (ev: ChatStreamEvent) => void,
signal?: AbortSignal
): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(chatWSURL());
let settled = false;
const finish = (err?: Error) => {
if (settled) return;
settled = true;
try {
ws.close();
} catch {
/* ignore */
}
if (err) reject(err);
else resolve();
};
if (signal) {
const abort = () => finish(new Error("aborted"));
if (signal.aborted) {
abort();
return;
}
signal.addEventListener("abort", abort, { once: true });
}
ws.onopen = () => {
ws.send(JSON.stringify({ messages }));
};
ws.onmessage = (e) => {
try {
const ev = JSON.parse(typeof e.data === "string" ? e.data : "") as ChatStreamEvent;
onEvent(ev);
if (ev.type === "done" || ev.type === "error") {
finish(ev.type === "error" ? new Error(ev.error) : undefined);
}
} catch (err) {
finish(err as Error);
}
};
ws.onerror = () => finish(new Error("websocket error"));
ws.onclose = () => finish();
});
}
export function login(username: string, password: string): Promise<User> {
@@ -172,6 +444,169 @@ export function listRequesters(): Promise<string[]> {
return fetchJSON("/requesters");
}
// --- Files (issue 0128) -----------------------------------------------------
export function listCardFiles(cardId: string): Promise<CardFile[]> {
return fetchJSON(`/cards/${cardId}/files`);
}
export async function uploadCardFile(
cardId: string,
file: File,
source: "upload" | "description" | "chat" = "upload"
): Promise<CardFile> {
const fd = new FormData();
fd.append("file", file);
fd.append("source", source);
const res = await fetch(`${BASE}/cards/${cardId}/files`, {
method: "POST",
credentials: "same-origin",
body: fd,
});
if (!res.ok) {
let msg = `upload failed: ${res.status}`;
try {
const body = (await res.json()) as { Message?: string; message?: string };
if (body.Message || body.message) msg = body.Message || body.message || msg;
} catch {
/* ignore */
}
throw new HTTPError(res.status, msg);
}
return (await res.json()) as CardFile;
}
export function deleteCardFile(fileId: string): Promise<void> {
return fetchJSON(`/files/${fileId}`, { method: "DELETE" });
}
// --- MCP per-user tokens ----------------------------------------------------
export interface MCPToken {
id: string;
name: string;
created_at: string;
last_used_at?: string;
}
export interface MCPTokenCreated extends MCPToken {
token: string;
}
export function createMCPToken(name: string): Promise<MCPTokenCreated> {
return fetchJSON("/mcp-tokens", { method: "POST", body: JSON.stringify({ name }) });
}
export function listMCPTokens(): Promise<MCPToken[]> {
return fetchJSON("/mcp-tokens");
}
export function revokeMCPToken(id: string): Promise<void> {
return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" });
}
// --- Jira sync state + import ----------------------------------------------
export interface CardJiraSyncState {
card_id: string;
jira_key: string;
last_status: string;
last_sync_at: string;
last_error: string;
inflight: boolean;
issue_url?: string;
}
export function getCardJiraSync(cardId: string): Promise<CardJiraSyncState> {
return fetchJSON(`/cards/${cardId}/jira-sync`);
}
export interface JiraIssue {
key: string;
summary: string;
status_name: string;
issue_type: string;
assignee: string;
updated: string;
url: string;
already_imported: boolean;
mapped_column_id?: string;
issue_type_icon?: string;
}
export interface ListJiraIssuesResponse {
issues: JiraIssue[];
board_id: number;
project_key: string;
status_to_column: Record<string, string>;
include_imported: boolean;
}
export function listJiraIssues(opts?: { includeImported?: boolean; limit?: number }): Promise<ListJiraIssuesResponse> {
const qs = new URLSearchParams();
if (opts?.includeImported) qs.set("include_imported", "true");
if (opts?.limit) qs.set("limit", String(opts.limit));
const q = qs.toString();
return fetchJSON(`/jira/issues${q ? `?${q}` : ""}`);
}
export interface JiraImportResult {
key: string;
status: "imported" | "skipped" | "error";
card_id?: string;
column_id?: string;
error?: string;
}
export function importJiraIssues(issueKeys: string[], fallbackColumnId?: string): Promise<{ results: JiraImportResult[] }> {
return fetchJSON("/jira/import", {
method: "POST",
body: JSON.stringify({ issue_keys: issueKeys, fallback_column_id: fallbackColumnId || "" }),
});
}
export interface JiraCheckRow {
card_id: string;
jira_key: string;
title: string;
kanban_column_id: string;
kanban_column_name: string;
jira_status_name: string;
expected_kanban_col: string;
expected_jira_status: string;
mismatch: boolean;
issue_url: string;
}
export interface JiraCheckResponse {
rows: JiraCheckRow[];
total: number;
mismatches: number;
in_sync: number;
status_map: Record<string, string>;
reverse_map: Record<string, string>;
}
export function checkJiraColumns(): Promise<JiraCheckResponse> {
return fetchJSON("/jira/check-columns");
}
export interface JiraReconcileResult {
card_id: string;
status: "fixed" | "skipped" | "error";
jira_key?: string;
jira_status?: string;
error?: string;
http?: number;
}
export function reconcileJiraColumns(cardIds: string[]): Promise<{ results: JiraReconcileResult[] }> {
return fetchJSON("/jira/reconcile-columns", {
method: "POST",
body: JSON.stringify({ card_ids: cardIds, direction: "kanban-wins" }),
});
}
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
const qs = new URLSearchParams();
if (f.from) qs.set("from", f.from);
+19 -4
View File
@@ -19,15 +19,17 @@ import { useEffect, useMemo, useState } from "react";
import * as api from "../api";
import type { Card, Metrics, User } from "../types";
// Hace clickable el numero del dia para abrir el reporte diario (issue 0093).
interface Props {
users: User[];
cards: Card[];
onJumpToCard?: (cardId: string) => void;
onOpenDailyReport?: (date: string) => void;
}
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
export function CalendarView({ users, cards, onJumpToCard }: Props) {
export function CalendarView({ users, cards, onJumpToCard, onOpenDailyReport }: Props) {
const [openDate, setOpenDate] = useState<string | null>(null);
const [month, setMonth] = useState<Date>(new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null);
@@ -199,9 +201,22 @@ export function CalendarView({ users, cards, onJumpToCard }: Props) {
}}
>
<Stack gap={2}>
<Text size="xs" fw={isToday ? 700 : 500} c={isToday ? "blue" : undefined}>
{dayNum}
</Text>
<UnstyledButton
onClick={() => cell.date && onOpenDailyReport?.(cell.date as string)}
title="Ver reporte diario"
style={{ alignSelf: "flex-start" }}
data-test={`calendar-day-${cell.date}`}
>
<Text
size="xs"
fw={isToday ? 700 : 500}
c={isToday ? "blue" : undefined}
td={onOpenDailyReport ? "underline" : undefined}
style={{ cursor: onOpenDailyReport ? "pointer" : "default" }}
>
{dayNum}
</Text>
</UnstyledButton>
{stats.created > 0 && (
<Group gap={3} wrap="nowrap">
<IconPlus size={10} color="var(--mantine-color-blue-5)" />
+522
View File
@@ -0,0 +1,522 @@
import {
ActionIcon,
Avatar,
Box,
Combobox,
FileButton,
Group,
Loader,
Paper,
ScrollArea,
Stack,
Text,
Textarea,
Tooltip,
useCombobox,
} from "@mantine/core";
import { IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import {
DragEvent,
KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import * as api from "../api";
import type { CardMessage, User } from "../types";
import { tagColor } from "./colors";
import { formatDateTimeShort } from "./format";
import { MessageBody } from "./MessageBody";
interface Props {
cardId: string;
users: User[];
currentUserId?: string;
onMessagesChange?: (messages: CardMessage[]) => void;
onFileUploaded?: () => void;
// When set, the panel scrolls the matching message into view and flashes a
// brief highlight (~2s). Used by notification click → open card.
highlightMessageId?: string;
}
function refForFile(filename: string, url: string, mime: string): string {
const safe = filename.replace(/]/g, "");
return mime.startsWith("image/") ? `![${safe}](${url})` : `[${safe}](${url})`;
}
// Window for considering a peer "actively typing" after its last event.
const TYPING_LIFETIME_MS = 4000;
// Minimum gap between successive typing pings emitted while the user types.
const TYPING_THROTTLE_MS = 1500;
interface MentionMatch {
start: number; // index of '@' in the textarea value
query: string; // text after '@', lowercased
}
function detectMention(value: string, cursor: number): MentionMatch | null {
// Look backwards from cursor for an '@' that starts a word.
for (let i = cursor - 1; i >= 0 && cursor - i <= 64; i--) {
const ch = value[i];
if (ch === "@") {
// Valid start: beginning of string or whitespace before.
if (i === 0 || /\s/.test(value[i - 1])) {
const q = value.slice(i + 1, cursor);
if (/^[a-z0-9_.-]*$/i.test(q)) {
return { start: i, query: q.toLowerCase() };
}
}
return null;
}
if (/\s/.test(ch)) return null;
}
return null;
}
export function CardChatPanel({
cardId,
users,
currentUserId,
onMessagesChange,
onFileUploaded,
highlightMessageId,
}: Props) {
const [messages, setMessages] = useState<CardMessage[]>([]);
const [loading, setLoading] = useState(true);
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [typingUsers, setTypingUsers] = useState<Record<string, number>>({});
const [mention, setMention] = useState<MentionMatch | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const lastTypingEmitRef = useRef(0);
const usersById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
const reload = useCallback(async () => {
try {
const ms = await api.listCardMessages(cardId);
setMessages(ms);
onMessagesChange?.(ms);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setLoading(false);
}
}, [cardId, onMessagesChange]);
useEffect(() => {
reload();
}, [reload]);
// Open one WebSocket per cardId for realtime chat + typing.
useEffect(() => {
const ws = new WebSocket(api.cardChatWSURL(cardId));
wsRef.current = ws;
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data) as
| { type: "message.created"; message: CardMessage }
| { type: "typing"; user_id: string }
| { type: "error"; error: string };
if (data.type === "message.created" && data.message) {
setMessages((prev) => {
if (prev.some((m) => m.id === data.message!.id)) return prev;
const next = [...prev, data.message!];
onMessagesChange?.(next);
return next;
});
} else if (data.type === "typing" && data.user_id) {
setTypingUsers((prev) => ({ ...prev, [data.user_id]: Date.now() }));
} else if (data.type === "error") {
notifications.show({ color: "red", message: data.error });
}
} catch {
// ignore malformed
}
};
ws.onerror = () => {
// browser will report; we keep the panel functional via REST fallback
};
return () => {
ws.close();
wsRef.current = null;
};
}, [cardId, onMessagesChange]);
// Sweep stale typing entries.
useEffect(() => {
const t = setInterval(() => {
const now = Date.now();
setTypingUsers((prev) => {
const next: Record<string, number> = {};
for (const [k, v] of Object.entries(prev)) {
if (now - v < TYPING_LIFETIME_MS) next[k] = v;
}
return next;
});
}, 1000);
return () => clearInterval(t);
}, []);
useEffect(() => {
if (viewportRef.current) {
viewportRef.current.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
}
}, [messages.length]);
// Scroll to + briefly pulse the message that triggered an incoming
// notification. Runs whenever the highlight id changes AND the message
// is present in the list (it may arrive asynchronously after WS sync).
const [pulse, setPulse] = useState<string | null>(null);
useEffect(() => {
if (!highlightMessageId) return;
if (!messages.some((m) => m.id === highlightMessageId)) return;
const el = document.querySelector(`[data-msg-id="${highlightMessageId}"]`);
if (el && el instanceof HTMLElement) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
}
setPulse(highlightMessageId);
const t = setTimeout(() => setPulse(null), 2200);
return () => clearTimeout(t);
}, [highlightMessageId, messages]);
const sendTypingPing = () => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const now = Date.now();
if (now - lastTypingEmitRef.current < TYPING_THROTTLE_MS) return;
lastTypingEmitRef.current = now;
ws.send(JSON.stringify({ type: "typing" }));
};
const combobox = useCombobox({
onDropdownClose: () => setMention(null),
});
const mentionCandidates = useMemo(() => {
if (!mention) return [] as User[];
return users
.filter((u) => u.username.toLowerCase().startsWith(mention.query))
.slice(0, 8);
}, [users, mention]);
useEffect(() => {
if (mention && mentionCandidates.length > 0) {
combobox.openDropdown();
combobox.selectFirstOption();
} else {
combobox.closeDropdown();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mention?.query, mentionCandidates.length]);
const insertMention = (username: string) => {
if (!mention) return;
const before = body.slice(0, mention.start);
const after = body.slice(mention.start + 1 + mention.query.length);
const inserted = `@${username} `;
const next = before + inserted + after;
setBody(next);
setMention(null);
// Restore caret right after the inserted mention.
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
const pos = (before + inserted).length;
el.focus();
el.setSelectionRange(pos, pos);
});
};
const send = async () => {
const text = body.trim();
if (!text || sending) return;
setSending(true);
const ws = wsRef.current;
try {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "send", body: text }));
// Optimistic clear; server will broadcast the persisted message.
setBody("");
} else {
const m = await api.createCardMessage(cardId, text);
setMessages((prev) => [...prev, m]);
onMessagesChange?.([...messages, m]);
setBody("");
}
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setSending(false);
}
};
const remove = async (mid: string) => {
try {
await api.deleteCardMessage(cardId, mid);
const next = messages.filter((m) => m.id !== mid);
setMessages(next);
onMessagesChange?.(next);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
};
const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setBody(e.currentTarget.value);
sendTypingPing();
const cursor = e.currentTarget.selectionStart ?? e.currentTarget.value.length;
setMention(detectMention(e.currentTarget.value, cursor));
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (mention && mentionCandidates.length > 0 && (e.key === "Enter" || e.key === "Tab")) {
e.preventDefault();
const sel = combobox.getSelectedOptionIndex();
const pick = mentionCandidates[Math.max(0, sel)];
if (pick) insertMention(pick.username);
return;
}
if (mention && e.key === "Escape") {
setMention(null);
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
const handleFiles = async (files: FileList | File[]) => {
setUploading(true);
try {
for (const file of Array.from(files)) {
try {
const cf = await api.uploadCardFile(cardId, file, "chat");
const ref = refForFile(cf.filename, cf.url, cf.mime);
const m = await api.createCardMessage(cardId, ref);
setMessages((prev) => {
const next = [...prev, m];
onMessagesChange?.(next);
return next;
});
onFileUploaded?.();
} catch (e) {
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
}
}
} finally {
setUploading(false);
}
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
handleFiles(e.dataTransfer.files);
};
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragOver(true);
};
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
};
const typingNames = Object.keys(typingUsers)
.filter((uid) => uid !== currentUserId)
.map((uid) => {
const u = usersById.get(uid);
return u?.display_name || u?.username || "alguien";
});
return (
<Stack
gap="xs"
style={{
height: "100%",
minHeight: 0,
position: "relative",
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
outlineOffset: dragOver ? -2 : undefined,
borderRadius: 4,
}}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
>
<ScrollArea
viewportRef={viewportRef}
style={{ flex: 1, minHeight: 200 }}
type="auto"
offsetScrollbars
>
{loading ? (
<Group justify="center" p="md"><Loader size="sm" /></Group>
) : messages.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" p="md">
Sin mensajes aun. Escribe el primero o arrastra un archivo.
</Text>
) : (
<Stack gap={6} p={4}>
{messages.map((m) => {
const author = m.author_id ? usersById.get(m.author_id) : null;
const isMe = m.author_id && m.author_id === currentUserId;
const label = author ? author.display_name || author.username : "Anonimo";
const highlighted = pulse === m.id;
return (
<Paper
key={m.id}
withBorder
p="xs"
radius="sm"
data-msg-id={m.id}
bg={
highlighted
? "var(--mantine-color-yellow-light)"
: isMe
? "var(--mantine-color-blue-light)"
: undefined
}
style={{
transition: "background-color 600ms ease",
boxShadow: highlighted ? "0 0 0 2px var(--mantine-color-yellow-5)" : undefined,
}}
>
<Group gap={6} wrap="nowrap" align="flex-start">
<Avatar size={22} radius="xl" color={author?.color || tagColor(label)}>
{label.slice(0, 2).toUpperCase()}
</Avatar>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600}>{label}</Text>
<Text size="xs" c="dimmed">{formatDateTimeShort(m.created_at)}</Text>
</Group>
{isMe && (
<Tooltip label="Borrar" withArrow>
<ActionIcon size="xs" variant="subtle" color="red" onClick={() => remove(m.id)}>
<IconTrash size={12} />
</ActionIcon>
</Tooltip>
)}
</Group>
<Stack gap={4}>
<MessageBody text={m.body} />
</Stack>
</Box>
</Group>
</Paper>
);
})}
</Stack>
)}
</ScrollArea>
{typingNames.length > 0 && (
<Text size="xs" c="dimmed" px={6}>
{typingNames.length === 1
? `${typingNames[0]} esta escribiendo...`
: `${typingNames.slice(0, 2).join(", ")}${typingNames.length > 2 ? "..." : ""} estan escribiendo...`}
</Text>
)}
<Combobox
store={combobox}
onOptionSubmit={(value) => insertMention(value)}
position="top-start"
withinPortal={false}
>
<Combobox.DropdownTarget>
<Group gap="xs" align="flex-end">
<Textarea
ref={textareaRef}
value={body}
onChange={onChange}
onKeyDown={onKeyDown}
placeholder="Escribe un mensaje (Enter = enviar, @ para mencionar). Arrastra archivos o usa el clip."
autosize
minRows={1}
maxRows={6}
style={{ flex: 1 }}
disabled={sending}
/>
<FileButton onChange={(file) => file && handleFiles([file])} disabled={uploading}>
{(props) => (
<Tooltip label="Adjuntar archivo" withArrow>
<ActionIcon
size="lg"
variant="subtle"
color="gray"
aria-label="Adjuntar"
loading={uploading}
{...props}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
)}
</FileButton>
<Tooltip label="Enviar" withArrow>
<ActionIcon
size="lg"
variant="filled"
color="blue"
onClick={send}
disabled={!body.trim() || sending}
aria-label="Enviar"
>
<IconSend size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Combobox.DropdownTarget>
<Combobox.Dropdown hidden={!mention || mentionCandidates.length === 0}>
<Combobox.Options>
{mentionCandidates.map((u) => (
<Combobox.Option key={u.id} value={u.username}>
<Group gap={6} wrap="nowrap">
<Avatar size={18} radius="xl" color={u.color || tagColor(u.username)}>
{(u.display_name || u.username).slice(0, 2).toUpperCase()}
</Avatar>
<Text size="sm" fw={600}>@{u.username}</Text>
{u.display_name && u.display_name !== u.username && (
<Text size="xs" c="dimmed">{u.display_name}</Text>
)}
</Group>
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{(dragOver || uploading) && (
<Box
style={{
position: "absolute",
inset: 0,
background: "rgba(34,139,230,0.08)",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
borderRadius: 4,
}}
>
<Text size="sm" fw={500} c="blue">
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
</Text>
</Box>
)}
</Stack>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { Box, Divider, Group, Tabs } from "@mantine/core";
import { IconLink, IconMessage, IconPaperclip } from "@tabler/icons-react";
import { useState } from "react";
import type { Card, CardMessage, User } from "../types";
import { CardChatPanel } from "./CardChatPanel";
import { CardFilesPanel } from "./CardFilesPanel";
import { CardLinksPanel } from "./CardLinksPanel";
import { CardForm, CardFormValues } from "./CardForm";
interface Props {
card: Card;
users: User[];
currentUserId?: string;
requesterOptions: string[];
tagOptions: string[];
onSubmit: (v: CardFormValues) => Promise<void> | void;
onCancel: () => void;
// When set, the chat panel auto-scrolls to this message id and pulses
// it briefly. Used when opening a card from a notification click.
highlightMessageId?: string;
}
export function CardEditPanel({
card,
users,
currentUserId,
requesterOptions,
tagOptions,
onSubmit,
onCancel,
highlightMessageId,
}: Props) {
const [messages, setMessages] = useState<CardMessage[]>([]);
const [liveCard, setLiveCard] = useState(card);
const [filesRefreshKey, setFilesRefreshKey] = useState(0);
const wrappedSubmit = async (v: CardFormValues) => {
setLiveCard((c) => ({ ...c, title: v.title, description: v.description, requester: v.requester, tags: v.tags, assignee_id: v.assignee_id }));
await onSubmit(v);
};
const bumpFiles = () => setFilesRefreshKey((k) => k + 1);
return (
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
<CardForm
users={users}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
initial={{
requester: liveCard.requester,
title: liveCard.title,
description: liveCard.description,
assignee_id: liveCard.assignee_id,
tags: liveCard.tags || [],
}}
submitLabel="Guardar"
cardId={liveCard.id}
onFileUploaded={bumpFiles}
onSubmit={wrappedSubmit}
onCancel={onCancel}
/>
</Box>
<Divider orientation="vertical" />
<Box style={{ flex: "1 1 0", minWidth: 320, display: "flex", flexDirection: "column" }}>
<Tabs defaultValue="chat" keepMounted={false} style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
<Tabs.List>
<Tabs.Tab value="chat" leftSection={<IconMessage size={14} />}>Chat</Tabs.Tab>
<Tabs.Tab value="links" leftSection={<IconLink size={14} />}>Enlaces</Tabs.Tab>
<Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />}>Archivos</Tabs.Tab>
</Tabs.List>
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
<Box style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", width: "100%" }}>
<CardChatPanel
cardId={liveCard.id}
users={users}
currentUserId={currentUserId}
onMessagesChange={setMessages}
onFileUploaded={bumpFiles}
highlightMessageId={highlightMessageId}
/>
</Box>
</Tabs.Panel>
<Tabs.Panel value="links">
<CardLinksPanel card={liveCard} messages={messages} />
</Tabs.Panel>
<Tabs.Panel value="files">
<CardFilesPanel cardId={liveCard.id} refreshKey={filesRefreshKey} />
</Tabs.Panel>
</Box>
</Tabs>
</Box>
</Group>
);
}
+223
View File
@@ -0,0 +1,223 @@
import {
ActionIcon,
Anchor,
Badge,
Box,
Button,
FileButton,
Group,
Image,
Loader,
Paper,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
IconDownload,
IconFile,
IconFileSpreadsheet,
IconFileText,
IconFileTypePdf,
IconPhoto,
IconTrash,
IconUpload,
} from "@tabler/icons-react";
import { useCallback, useEffect, useState } from "react";
import * as api from "../api";
import type { CardFile } from "../types";
interface Props {
cardId: string;
refreshKey?: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function isImage(mime: string): boolean {
return mime.startsWith("image/");
}
function fileIcon(mime: string, size = 18) {
const m = mime.toLowerCase();
if (m.startsWith("image/")) return <IconPhoto size={size} />;
if (m === "application/pdf") return <IconFileTypePdf size={size} />;
if (
m.includes("spreadsheet") ||
m.includes("excel") ||
m === "text/csv" ||
m === "application/vnd.ms-excel"
) {
return <IconFileSpreadsheet size={size} />;
}
if (m.startsWith("text/")) return <IconFileText size={size} />;
return <IconFile size={size} />;
}
function sourceBadge(s: CardFile["source"]) {
if (s === "description") return { color: "blue", label: "descripcion" };
if (s === "chat") return { color: "teal", label: "chat" };
return { color: "gray", label: "subido" };
}
export function CardFilesPanel({ cardId, refreshKey }: Props) {
const [files, setFiles] = useState<CardFile[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const reload = useCallback(async () => {
try {
const list = await api.listCardFiles(cardId);
setFiles(list);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setLoading(false);
}
}, [cardId]);
useEffect(() => {
reload();
}, [reload, refreshKey]);
const onUpload = async (file: File | null) => {
if (!file) return;
setUploading(true);
try {
const cf = await api.uploadCardFile(cardId, file, "upload");
setFiles((prev) => [...prev, cf]);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setUploading(false);
}
};
const onDelete = async (id: string) => {
if (!window.confirm("¿Borrar este archivo?")) return;
try {
await api.deleteCardFile(id);
setFiles((prev) => prev.filter((f) => f.id !== id));
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
};
return (
<Stack gap="xs" p={4}>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{files.length} archivo{files.length === 1 ? "" : "s"}
</Text>
<FileButton onChange={onUpload} disabled={uploading}>
{(props) => (
<Button
size="xs"
variant="light"
leftSection={<IconUpload size={14} />}
loading={uploading}
{...props}
>
Subir
</Button>
)}
</FileButton>
</Group>
{loading ? (
<Group justify="center" p="md">
<Loader size="sm" />
</Group>
) : files.length === 0 ? (
<Stack gap="xs" p="md" align="center" justify="center" style={{ minHeight: 160 }}>
<Text size="sm" c="dimmed">
Sin archivos
</Text>
<Text size="xs" c="dimmed" ta="center">
Sube archivos con el boton, arrastra al chat o a la descripcion.
</Text>
</Stack>
) : (
<Stack gap={6}>
{files.map((f) => {
const badge = sourceBadge(f.source);
return (
<Paper key={f.id} withBorder p="xs" radius="sm">
<Group gap="xs" wrap="nowrap" align="flex-start">
{isImage(f.mime) ? (
<Anchor href={f.url} target="_blank" rel="noopener noreferrer">
<Image
src={f.url}
alt={f.filename}
w={64}
h={64}
fit="cover"
radius="sm"
/>
</Anchor>
) : (
<Box
style={{
width: 64,
height: 64,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--mantine-color-gray-1)",
borderRadius: 4,
}}
>
{fileIcon(f.mime, 28)}
</Box>
)}
<Box style={{ flex: 1, minWidth: 0 }}>
<Anchor href={f.url} target="_blank" rel="noopener noreferrer" size="sm" style={{ wordBreak: "break-all" }}>
{f.filename}
</Anchor>
<Group gap={6} mt={4}>
<Badge size="xs" variant="light" color={badge.color}>
{badge.label}
</Badge>
<Text size="xs" c="dimmed">{formatSize(f.size)}</Text>
<Text size="xs" c="dimmed">{f.mime || "?"}</Text>
</Group>
</Box>
<Group gap={4} wrap="nowrap">
<Tooltip label="Descargar" withArrow>
<ActionIcon
component="a"
href={f.url}
download={f.filename}
variant="subtle"
size="sm"
aria-label="Descargar"
>
<IconDownload size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label="Borrar" withArrow>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => onDelete(f.id)}
aria-label="Borrar"
>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
);
})}
</Stack>
)}
</Stack>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MantineProvider } from "@mantine/core";
import { CardForm } from "./CardForm";
function renderForm(overrides: Partial<Parameters<typeof CardForm>[0]> = {}) {
const onSubmit = vi.fn();
const onCancel = vi.fn();
render(
<MantineProvider>
<CardForm
requesterOptions={["Alice", "Anna", "Bob", "Enmanuel"]}
onSubmit={onSubmit}
onCancel={onCancel}
{...overrides}
/>
</MantineProvider>
);
return { onSubmit, onCancel };
}
describe("CardForm — requester input (issue 0088)", () => {
it("solicitante entra vacio cuando initial.requester no se pasa", () => {
renderForm();
const requesterInput = (document.querySelector('input[data-field="requester"]') as HTMLInputElement) as HTMLInputElement;
expect(requesterInput.value).toBe("");
});
it("Enter dentro del requester NO dispara onSubmit (dropdown cerrado o abierto)", async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm();
// Necesita un titulo valido para que un eventual submit no se ignore por el guard.
const title = screen.getByLabelText(/Tarea/i);
await user.type(title, "Mi tarea");
const requester = (document.querySelector('input[data-field="requester"]') as HTMLInputElement);
await user.click(requester);
await user.keyboard("{Enter}");
expect(onSubmit).not.toHaveBeenCalled();
await user.type(requester, "An");
await user.keyboard("{Enter}");
expect(onSubmit).not.toHaveBeenCalled();
});
// Navegacion ArrowDown + Enter del dropdown la maneja Mantine internamente.
// Validar eso en jsdom es fragil (portals + virtual focus). Cubierto en e2e
// Playwright donde corre browser real.
it("submit solo via boton Crear", async () => {
const user = userEvent.setup();
const { onSubmit } = renderForm({ submitLabel: "Crear" });
const title = screen.getByLabelText(/Tarea/i);
await user.type(title, "Mi tarea");
const requester = (document.querySelector('input[data-field="requester"]') as HTMLInputElement);
await user.type(requester, "Anna");
await user.click(screen.getByRole("button", { name: /Crear/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
expect(onSubmit.mock.calls[0][0]).toMatchObject({
title: "Mi tarea",
requester: "Anna",
});
});
});
+127 -20
View File
@@ -1,7 +1,14 @@
import { Autocomplete, Button, Group, Select, Stack, TagsInput, Textarea } from "@mantine/core";
import { FormEvent, KeyboardEvent, useState } from "react";
import { Autocomplete, Box, Button, Group, Select, Stack, TagsInput, Text, Textarea } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { DragEvent, FormEvent, KeyboardEvent, useRef, useState } from "react";
import * as api from "../api";
import type { User } from "../types";
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
// Enter dentro del Autocomplete deja que Mantine seleccione el item resaltado del
// dropdown sin cerrar el formulario. Submit solo via boton "Crear" o Ctrl+Enter
// en descripcion. Ver issue 0088.
export interface CardFormValues {
requester: string;
title: string;
@@ -16,16 +23,25 @@ interface Props {
users?: User[];
requesterOptions?: string[];
tagOptions?: string[];
cardId?: string;
onFileUploaded?: () => void;
onSubmit: (v: CardFormValues) => Promise<void> | void;
onCancel: () => void;
}
function markdownRef(filename: string, url: string, isImage: boolean): string {
const safeName = filename.replace(/]/g, "");
return isImage ? `![${safeName}](${url})` : `[${safeName}](${url})`;
}
export function CardForm({
initial,
submitLabel = "Guardar",
users = [],
requesterOptions = [],
tagOptions = [],
cardId,
onFileUploaded,
onSubmit,
onCancel,
}: Props) {
@@ -34,6 +50,9 @@ export function CardForm({
const [description, setDescription] = useState(initial?.description ?? "");
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const submit = async (e?: FormEvent) => {
e?.preventDefault();
@@ -48,12 +67,6 @@ export function CardForm({
});
};
const enterSubmit = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const textareaEnter = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
@@ -61,6 +74,66 @@ export function CardForm({
}
};
const insertAtCursor = (snippet: string) => {
const ta = textareaRef.current;
if (!ta) {
setDescription((d) => (d ? d + "\n" + snippet : snippet));
return;
}
const start = ta.selectionStart ?? description.length;
const end = ta.selectionEnd ?? description.length;
const before = description.slice(0, start);
const after = description.slice(end);
const sep = before && !before.endsWith("\n") ? "\n" : "";
const next = before + sep + snippet + after;
setDescription(next);
queueMicrotask(() => {
ta.focus();
const pos = (before + sep + snippet).length;
ta.setSelectionRange(pos, pos);
});
};
const handleFiles = async (files: FileList | File[]) => {
if (!cardId) {
notifications.show({ color: "yellow", message: "Guarda la tarjeta antes de subir archivos." });
return;
}
setUploading(true);
try {
for (const file of Array.from(files)) {
try {
const cf = await api.uploadCardFile(cardId, file, "description");
insertAtCursor(markdownRef(cf.filename, cf.url, cf.mime.startsWith("image/")));
onFileUploaded?.();
} catch (e) {
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
}
}
} finally {
setUploading(false);
}
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
handleFiles(e.dataTransfer.files);
};
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!cardId) return;
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragOver(true);
};
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
};
return (
<form onSubmit={submit}>
<Stack gap="sm">
@@ -89,21 +162,55 @@ export function CardForm({
data={requesterOptions}
tabIndex={2}
autoComplete="off"
onKeyDown={enterSubmit}
data-field="requester"
placeholder="Empieza a escribir y elige uno existente"
limit={10}
onKeyDown={(e) => {
if (e.key === "Enter") e.preventDefault();
}}
/>
<Textarea
label="Descripcion"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
tabIndex={3}
autosize
minRows={3}
maxRows={8}
onKeyDown={textareaEnter}
description="Ctrl+Enter para guardar"
/>
<Box
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
style={{
position: "relative",
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
outlineOffset: dragOver ? 2 : undefined,
borderRadius: 4,
}}
>
<Textarea
ref={textareaRef}
label="Descripcion"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
tabIndex={3}
autosize
minRows={3}
maxRows={8}
onKeyDown={textareaEnter}
description={cardId ? "Ctrl+Enter para guardar. Arrastra archivos para adjuntar." : "Ctrl+Enter para guardar"}
/>
{(dragOver || uploading) && (
<Box
style={{
position: "absolute",
inset: 0,
background: "rgba(34,139,230,0.08)",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
borderRadius: 4,
}}
>
<Text size="sm" fw={500} c="blue">
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
</Text>
</Box>
)}
</Box>
<Select
label="Asignar a"
placeholder="Sin asignar"
+104
View File
@@ -0,0 +1,104 @@
import { Anchor, Badge, Box, Group, Paper, Stack, Text } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { useMemo } from "react";
import type { Card, CardMessage } from "../types";
interface ExtractedLink {
url: string;
source: "title" | "description" | "chat";
context: string;
}
const URL_RE = /(https?:\/\/[^\s<>()"']+)/gi;
function extract(source: ExtractedLink["source"], text: string): ExtractedLink[] {
if (!text) return [];
const out: ExtractedLink[] = [];
const seen = new Set<string>();
let m: RegExpExecArray | null;
URL_RE.lastIndex = 0;
while ((m = URL_RE.exec(text)) !== null) {
let url = m[1];
// Strip common trailing punctuation that isn't part of a URL.
url = url.replace(/[.,;:!?)\]}>]+$/, "");
if (seen.has(url)) continue;
seen.add(url);
out.push({ url, source, context: text });
}
return out;
}
function hostname(u: string): string {
try {
return new URL(u).hostname;
} catch {
return u;
}
}
interface Props {
card: Card;
messages: CardMessage[];
}
export function CardLinksPanel({ card, messages }: Props) {
const links = useMemo<ExtractedLink[]>(() => {
const all: ExtractedLink[] = [
...extract("title", card.title),
...extract("description", card.description),
...messages.flatMap((m) => extract("chat", m.body)),
];
const seen = new Set<string>();
return all.filter((l) => {
if (seen.has(l.url)) return false;
seen.add(l.url);
return true;
});
}, [card.title, card.description, messages]);
if (links.length === 0) {
return (
<Stack gap="xs" p="md" align="center" justify="center" style={{ minHeight: 200 }}>
<Text size="sm" c="dimmed">Sin enlaces detectados</Text>
<Text size="xs" c="dimmed" ta="center">
Pega URLs en el titulo, descripcion o chat y apareceran aqui.
</Text>
</Stack>
);
}
const badgeColor = (s: ExtractedLink["source"]): string => {
if (s === "title") return "grape";
if (s === "description") return "blue";
return "teal";
};
const badgeLabel = (s: ExtractedLink["source"]): string => {
if (s === "title") return "titulo";
if (s === "description") return "descripcion";
return "chat";
};
return (
<Stack gap={6} p={4}>
{links.map((l) => (
<Paper key={l.url} withBorder p="xs" radius="sm">
<Group gap="xs" wrap="nowrap" justify="space-between" align="flex-start">
<Box style={{ flex: 1, minWidth: 0 }}>
<Anchor href={l.url} target="_blank" rel="noopener noreferrer" size="sm" style={{ wordBreak: "break-all" }}>
<Group gap={4} wrap="nowrap" align="center">
<IconExternalLink size={12} />
<span>{hostname(l.url)}</span>
</Group>
</Anchor>
<Text size="xs" c="dimmed" style={{ wordBreak: "break-all" }}>{l.url}</Text>
</Box>
<Badge size="xs" variant="light" color={badgeColor(l.source)}>
{badgeLabel(l.source)}
</Badge>
</Group>
</Paper>
))}
</Stack>
);
}
+96 -23
View File
@@ -16,7 +16,7 @@ import { IconMessageChatbot, IconSend, IconTrash } from "@tabler/icons-react";
import { KeyboardEvent, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ChatMessage, ChatToolCall, sendChat } from "../api";
import { ChatMessage, ChatStreamEvent, ChatToolCall, streamChat } from "../api";
const STORAGE_KEY = "kanban_chat_v1";
@@ -44,7 +44,11 @@ function loadStored(): StoredMessage[] {
export function ChatPanel({ onBoardChange }: Props) {
const [messages, setMessages] = useState<StoredMessage[]>(() => loadStored());
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
// Live in-flight assistant turn: incremental text + tool calls collected so
// far. When the turn finishes (done/error) it is committed to messages.
const [liveText, setLiveText] = useState("");
const [liveCalls, setLiveCalls] = useState<ChatToolCall[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -53,35 +57,89 @@ export function ChatPanel({ onBoardChange }: Props) {
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages, loading]);
}, [messages, liveText, liveCalls, streaming]);
const send = async () => {
const text = input.trim();
if (!text || loading) return;
if (!text || streaming) return;
const userMsg: StoredMessage = { role: "user", content: text, ts: Date.now() };
const next = [...messages, userMsg];
setMessages(next);
setInput("");
setLoading(true);
setStreaming(true);
setLiveText("");
setLiveCalls([]);
let accumulatedText = "";
const accumulatedCalls: ChatToolCall[] = [];
let boardChanged = false;
const onEvent = (ev: ChatStreamEvent) => {
switch (ev.type) {
case "delta":
accumulatedText += ev.text;
setLiveText(accumulatedText);
break;
case "tool_use": {
const call: ChatToolCall = { tool: ev.tool, ok: true, input: ev.input };
accumulatedCalls.push(call);
setLiveCalls([...accumulatedCalls]);
break;
}
case "tool_result": {
// Map by reverse order: the latest tool_use without is_error set.
for (let i = accumulatedCalls.length - 1; i >= 0; i--) {
const c = accumulatedCalls[i];
if (c.error === undefined && c.ok) {
if (ev.is_error) {
c.ok = false;
c.error = ev.result || "tool error";
}
break;
}
}
setLiveCalls([...accumulatedCalls]);
break;
}
case "result":
if (ev.text) {
// Final result text replaces the streamed delta only when no
// delta was emitted (some claude paths only emit the final).
if (accumulatedText.trim() === "") {
accumulatedText = ev.text;
setLiveText(accumulatedText);
}
}
break;
case "done":
if (ev.board_changed) boardChanged = true;
break;
case "error":
accumulatedText = `Error: ${ev.error}`;
setLiveText(accumulatedText);
break;
}
};
try {
const payload: ChatMessage[] = next.map((m) => ({ role: m.role, content: m.content }));
const res = await sendChat(payload);
await streamChat(payload, onEvent);
} catch (e) {
const msg = (e as Error).message;
notifications.show({ color: "red", message: msg });
accumulatedText = accumulatedText || `Error: ${msg}`;
} finally {
const assistant: StoredMessage = {
role: "assistant",
content: res.content,
content: accumulatedText,
ts: Date.now(),
tool_calls: res.tool_calls,
tool_calls: accumulatedCalls.length > 0 ? accumulatedCalls : undefined,
};
setMessages((prev) => [...prev, assistant]);
if (res.board_changed) onBoardChange();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
setMessages((prev) => [
...prev,
{ role: "assistant", content: `Error: ${(e as Error).message}`, ts: Date.now() },
]);
} finally {
setLoading(false);
setLiveText("");
setLiveCalls([]);
setStreaming(false);
if (boardChanged) onBoardChange();
}
};
@@ -115,7 +173,7 @@ export function ChatPanel({ onBoardChange }: Props) {
<ScrollArea viewportRef={scrollRef} style={{ flex: 1 }} type="auto" p="xs">
<Stack gap="xs">
{messages.length === 0 && (
{messages.length === 0 && !streaming && (
<Text size="sm" c="dimmed" ta="center" mt="md">
Escribe algo. Ejemplos:
<br />- "crea columna Backlog"
@@ -126,7 +184,18 @@ export function ChatPanel({ onBoardChange }: Props) {
{messages.map((m, i) => (
<ChatBubble key={i} msg={m} />
))}
{loading && (
{streaming && (
<ChatBubble
msg={{
role: "assistant",
content: liveText,
ts: Date.now(),
tool_calls: liveCalls.length > 0 ? liveCalls : undefined,
}}
streaming
/>
)}
{streaming && liveText === "" && liveCalls.length === 0 && (
<Group gap={6} pl="xs">
<Loader size="xs" />
<Text size="xs" c="dimmed">
@@ -144,7 +213,7 @@ export function ChatPanel({ onBoardChange }: Props) {
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={onKey}
disabled={loading}
disabled={streaming}
autosize
minRows={1}
maxRows={6}
@@ -154,10 +223,10 @@ export function ChatPanel({ onBoardChange }: Props) {
size="lg"
variant="filled"
onClick={send}
disabled={!input.trim() || loading}
disabled={!input.trim() || streaming}
aria-label="Send"
>
{loading ? <Loader size="xs" color="white" /> : <IconSend size={16} />}
{streaming ? <Loader size="xs" color="white" /> : <IconSend size={16} />}
</ActionIcon>
</Group>
</Stack>
@@ -165,7 +234,7 @@ export function ChatPanel({ onBoardChange }: Props) {
);
}
function ChatBubble({ msg }: { msg: StoredMessage }) {
function ChatBubble({ msg, streaming = false }: { msg: StoredMessage; streaming?: boolean }) {
const isUser = msg.role === "user";
return (
<Paper
@@ -184,6 +253,9 @@ function ChatBubble({ msg }: { msg: StoredMessage }) {
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</Box>
)}
{streaming && msg.content && (
<Box style={{ display: "inline-block", width: 8, height: 14, background: "currentColor", opacity: 0.6 }} />
)}
{msg.tool_calls && msg.tool_calls.length > 0 && (
<Group gap={4} wrap="wrap">
{msg.tool_calls.map((c, i) => (
@@ -193,6 +265,7 @@ function ChatBubble({ msg }: { msg: StoredMessage }) {
color={c.ok ? "teal" : "red"}
variant="light"
title={c.error || ""}
leftSection={c.ok && streaming ? <Loader size={8} color="teal" /> : null}
>
{c.tool}
{!c.ok && c.error ? `: ${c.error}` : ""}
+810
View File
@@ -0,0 +1,810 @@
import {
ActionIcon,
Alert,
Avatar,
Badge,
Box,
Button,
Card as MCard,
Divider,
Group,
Loader,
Modal,
Paper,
ScrollArea,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
Title,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { BarChart } from "@mantine/charts";
import {
IconAlertTriangle,
IconArrowBackUp,
IconCalendarStats,
IconCheck,
IconClock,
IconDownload,
IconHourglass,
IconLock,
IconPlus,
IconRefresh,
IconSettings,
IconSparkles,
IconTrendingUp,
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import {
dailyReport,
generateDailySummary,
getDailySummary,
getSetting,
setSetting,
type DailyReport as Report,
type DailySummary,
} from "../api";
import { formatDuration } from "./format";
import { tagColor } from "./colors";
interface Props {
date: string; // YYYY-MM-DD
onJumpToCard?: (cardId: string) => void;
}
const PROMPT_KEY = "daily_report_prompt";
const PROMPT_DEFAULT =
"Eres un coach de equipo. Resume el reporte diario en un MAXIMO de 4 frases cortas, mencionando: (1) total de tareas hechas y quien destaco, (2) cualquier card reabierta o deadline vencido que merezca atencion, (3) cards estancadas criticas (30+ dias) si las hay, (4) una frase corta de animo o aviso si toca. Tono natural, primera persona del plural, sin emojis. No inventes datos; usa solo los del JSON del reporte.";
function fmtDate(s: string): string {
try {
const d = new Date(s + "T00:00:00");
return d.toLocaleDateString("es-ES", {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
} catch {
return s;
}
}
function KPI({
label,
value,
color,
icon,
sub,
}: {
label: string;
value: string | number;
color?: string;
icon?: React.ReactNode;
sub?: string;
}) {
return (
<Paper p="sm" withBorder radius="md">
<Group gap={6} mb={2} align="center">
{icon}
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
</Group>
<Text fz={28} fw={700} c={color}>
{value}
</Text>
{sub && (
<Text size="xs" c="dimmed">
{sub}
</Text>
)}
</Paper>
);
}
function RankingList<T extends { name: string; count: number; user_id?: string }>({
title,
rows,
emptyText,
withAvatar = false,
}: {
title: string;
rows: T[];
emptyText: string;
withAvatar?: boolean;
}) {
return (
<MCard withBorder radius="md" p="sm">
<Text fw={600} size="sm" mb={6}>
{title}
</Text>
{rows.length === 0 ? (
<Text size="xs" c="dimmed">
{emptyText}
</Text>
) : (
<Stack gap={4}>
{rows.map((r, i) => (
<Group key={(r.user_id || r.name) + i} gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
{withAvatar && (
<Avatar size={22} radius="xl" color={tagColor(r.name || String(i))}>
{(r.name || "?").slice(0, 2).toUpperCase()}
</Avatar>
)}
<Text size="sm" truncate>
{r.name || "(sin nombre)"}
</Text>
</Group>
<Badge size="sm" variant="light" color={i === 0 ? "teal" : "gray"}>
{r.count}
</Badge>
</Group>
))}
</Stack>
)}
</MCard>
);
}
export function DailyReportView({ date, onJumpToCard }: Props) {
const [data, setData] = useState<Report | null>(null);
const [err, setErr] = useState<string | null>(null);
const [summary, setSummary] = useState<DailySummary | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryErr, setSummaryErr] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [promptDraft, setPromptDraft] = useState("");
const [filterRequester, setFilterRequester] = useState<string | null>(null);
const [filterAssignee, setFilterAssignee] = useState<string | null>(null);
useEffect(() => {
setData(null);
setErr(null);
dailyReport(date)
.then(setData)
.catch((e) => setErr((e as Error).message));
setSummary(null);
setSummaryErr(null);
getDailySummary(date)
.then((s) => setSummary(s.exists ? s : null))
.catch(() => {});
}, [date]);
const regenerateSummary = async () => {
setSummaryLoading(true);
setSummaryErr(null);
try {
const s = await generateDailySummary(date);
setSummary({ ...s, exists: true });
} catch (e) {
setSummaryErr((e as Error).message);
} finally {
setSummaryLoading(false);
}
};
const openSettings = async () => {
try {
const s = await getSetting(PROMPT_KEY);
setPromptDraft(s.value || PROMPT_DEFAULT);
} catch {
setPromptDraft(PROMPT_DEFAULT);
}
setSettingsOpen(true);
};
const saveSettings = async () => {
await setSetting(PROMPT_KEY, promptDraft);
setSettingsOpen(false);
};
const resetSettings = () => setPromptDraft(PROMPT_DEFAULT);
const hourlyChartData = useMemo(() => {
if (!data) return [];
return data.hourly_moves.map((n, h) => ({
hora: String(h).padStart(2, "0") + ":00",
movimientos: n,
}));
}, [data]);
const requesterOptions = useMemo(() => {
if (!data) return [];
const set = new Set<string>();
for (const c of data.done_cards) if (c.requester) set.add(c.requester);
return Array.from(set).sort();
}, [data]);
const assigneeOptions = useMemo(() => {
if (!data) return [];
const m = new Map<string, string>();
for (const c of data.done_cards) {
if (c.assignee_id) m.set(c.assignee_id, c.assignee_name || c.assignee_id);
}
return Array.from(m.entries()).map(([value, label]) => ({ value, label }));
}, [data]);
const filteredDoneCards = useMemo(() => {
if (!data) return [];
return data.done_cards.filter((c) => {
if (filterRequester && c.requester !== filterRequester) return false;
if (filterAssignee && c.assignee_id !== filterAssignee) return false;
return true;
});
}, [data, filterRequester, filterAssignee]);
const exportPDF = () => {
if (!data) return;
const win = window.open("", "_blank");
if (!win) return;
const origin = window.location.origin;
const dateLabel = (() => {
try {
return new Date(data.date + "T00:00:00").toLocaleDateString("es-ES", {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
} catch {
return data.date;
}
})();
const filterSub: string[] = [];
if (filterRequester) filterSub.push(`solicitante=${filterRequester}`);
if (filterAssignee) {
const a = assigneeOptions.find((o) => o.value === filterAssignee);
filterSub.push(`asignado=${a?.label || filterAssignee}`);
}
const escape = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const rows = filteredDoneCards
.map((c) => {
const tags = (c.tags || []).map(escape).join(", ");
const link = `${origin}/?card=${c.id}`;
return `<tr>
<td class="num">${String(c.seq_num).padStart(5, "0")}</td>
<td><a href="${link}">${escape(c.title)}</a></td>
<td>${escape(c.requester || "")}</td>
<td>${escape(c.assignee_name || "")}</td>
<td>${escape(tags)}</td>
<td class="num">${formatDuration(c.lead_time_ms)}</td>
</tr>`;
})
.join("");
const html = `<!doctype html>
<html lang="es"><head><meta charset="utf-8" />
<title>Reporte ${data.date}</title>
<style>
@page { margin: 18mm 15mm; }
body { font-family: system-ui, sans-serif; color: #222; }
h1 { font-size: 18pt; margin-bottom: 4px; }
.sub { color: #666; font-size: 10pt; margin-bottom: 16px; }
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 18px; }
.kpi { border: 1px solid #ddd; border-radius: 6px; padding: 8px; }
.kpi .l { font-size: 8pt; color: #888; text-transform: uppercase; }
.kpi .v { font-size: 16pt; font-weight: 700; }
table { width: 100%; border-collapse: collapse; font-size: 9pt; }
th, td { border-bottom: 1px solid #e5e5e5; padding: 6px 4px; text-align: left; vertical-align: top; }
th { background: #f5f5f5; font-weight: 600; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
a { color: #1c7ed6; text-decoration: none; }
a:hover { text-decoration: underline; }
footer { margin-top: 20px; font-size: 8pt; color: #888; }
</style></head><body>
<h1>Reporte diario · ${escape(dateLabel)}</h1>
<div class="sub">${escape(data.date)} · ${escape(data.tz)}${
filterSub.length ? " · filtros: " + filterSub.map(escape).join(", ") : ""
}</div>
<div class="kpis">
<div class="kpi"><div class="l">Hechas</div><div class="v">${filteredDoneCards.length}</div></div>
<div class="kpi"><div class="l">Lead time avg</div><div class="v">${formatDuration(data.lead_time.avg_ms)}</div></div>
<div class="kpi"><div class="l">Deadlines on-time</div><div class="v">${data.deadlines.met}/${data.deadlines.met + data.deadlines.missed}</div></div>
<div class="kpi"><div class="l">Reabiertas</div><div class="v">${data.kpis.reopened}</div></div>
</div>
${summary?.summary ? `<p style="border-left:4px solid #1c7ed6; padding:8px 12px; background:#eef6fd; border-radius:4px;">${escape(summary.summary)}</p>` : ""}
<table>
<thead><tr>
<th class="num">#</th>
<th>Titulo</th>
<th>Solicitante</th>
<th>Asignado</th>
<th>Tags</th>
<th class="num">Lead time</th>
</tr></thead>
<tbody>${rows || '<tr><td colspan="6" style="text-align:center;color:#888;">Sin tareas que cumplan el filtro.</td></tr>'}</tbody>
</table>
<footer>Generado por kanban · ${escape(origin)}</footer>
<script>window.addEventListener("load", () => setTimeout(() => window.print(), 250));</script>
</body></html>`;
win.document.write(html);
win.document.close();
};
if (err) {
return (
<Alert color="red" icon={<IconAlertTriangle size={14} />}>
{err}
</Alert>
);
}
if (!data) {
return (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
);
}
const k = data.kpis;
const onTimePct =
k.deadlines_met + k.deadlines_missed > 0
? Math.round((k.deadlines_met / (k.deadlines_met + k.deadlines_missed)) * 100)
: null;
return (
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Group gap={6}>
<IconCalendarStats size={20} />
<Title order={4}>Reporte diario</Title>
</Group>
<Text size="sm" c="dimmed" tt="capitalize">
{fmtDate(data.date)}
</Text>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4, md: 6 }} spacing="xs">
<KPI label="Hechas" value={k.done} color="teal" icon={<IconCheck size={14} color="var(--mantine-color-teal-6)" />} />
<KPI label="Creadas" value={k.created} icon={<IconPlus size={14} />} />
<KPI label="Movimientos" value={k.moves} icon={<IconRefresh size={14} />} />
<KPI
label="Bloqueado"
value={formatDuration(k.blocked_ms)}
color="yellow"
icon={<IconLock size={14} color="var(--mantine-color-yellow-6)" />}
/>
<KPI
label="Reabiertas"
value={k.reopened}
color={k.reopened > 0 ? "orange" : undefined}
icon={<IconArrowBackUp size={14} />}
/>
<KPI
label="Deadlines"
value={onTimePct != null ? `${onTimePct}%` : "—"}
color={onTimePct == null ? "dimmed" : onTimePct >= 80 ? "teal" : "red"}
sub={`${k.deadlines_met} on-time / ${k.deadlines_missed} vencidos`}
icon={<IconHourglass size={14} />}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="xs">
<RankingList
title="Asignado: mas hechas"
rows={data.top_assignees_done}
emptyText="Sin hechas con asignado."
withAvatar
/>
<RankingList
title="Asignado: mas creadas"
rows={data.top_assignees_created}
emptyText="Sin actor en creadas."
withAvatar
/>
<RankingList
title="Solicitante: mas atendidas"
rows={data.top_requesters_done}
emptyText="Sin solicitantes con hechas."
/>
<RankingList
title="Solicitante: mas aportadas"
rows={data.top_requesters_added}
emptyText="Sin nuevas con solicitante."
/>
</SimpleGrid>
{/* Bocadillo de agente — encima de tareas hechas */}
<Paper
withBorder
radius="md"
p="sm"
bg="var(--mantine-color-blue-light)"
style={{ borderLeftWidth: 4, borderLeftColor: "var(--mantine-color-blue-6)" }}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={6} align="flex-start" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<IconSparkles size={18} color="var(--mantine-color-blue-6)" style={{ flexShrink: 0, marginTop: 2 }} />
<Box style={{ flex: 1 }}>
{summaryErr && (
<Alert color="red" mb={4} icon={<IconAlertTriangle size={14} />}>
{summaryErr}
</Alert>
)}
{summaryLoading ? (
<Group gap={6}><Loader size="xs" /><Text size="sm" c="dimmed">Generando resumen</Text></Group>
) : summary?.summary ? (
<>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>{summary.summary}</Text>
{summary.generated_at && (
<Text size="xs" c="dimmed" mt={4}>
Generado {new Date(summary.generated_at).toLocaleString()} · {summary.model}
</Text>
)}
</>
) : (
<Text size="sm" c="dimmed" fs="italic">Aun no hay resumen del dia. Pulsa "Generar".</Text>
)}
</Box>
</Group>
<Group gap={4} wrap="nowrap">
<Tooltip label={summary?.exists ? "Regenerar" : "Generar"} withArrow>
<ActionIcon variant="subtle" color="blue" onClick={regenerateSummary} loading={summaryLoading} aria-label="Regenerar resumen">
<IconRefresh size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Configurar prompt" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={openSettings} aria-label="Configurar prompt">
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
<MCard withBorder radius="md" p="sm">
<Group justify="space-between" mb="xs" wrap="wrap" gap={6}>
<Group gap={6} wrap="wrap">
<Text fw={600} size="sm">
Tareas hechas
</Text>
<Badge size="xs" variant="light">
N {filteredDoneCards.length}
{filteredDoneCards.length !== data.done_cards.length ? ` / ${data.done_cards.length}` : ""}
</Badge>
<Text size="xs" c="dimmed">
Lead time avg {data.lead_time.samples > 0 ? formatDuration(data.lead_time.avg_ms) : "—"} · p50{" "}
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p50_ms) : "—"} · p95{" "}
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p95_ms) : "—"}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<Select
size="xs"
placeholder="Solicitante"
data={requesterOptions}
value={filterRequester}
onChange={setFilterRequester}
clearable
searchable
style={{ width: 160 }}
aria-label="Filtrar por solicitante"
/>
<Select
size="xs"
placeholder="Asignado"
data={assigneeOptions}
value={filterAssignee}
onChange={setFilterAssignee}
clearable
searchable
style={{ width: 160 }}
aria-label="Filtrar por asignado"
/>
<Button
size="xs"
leftSection={<IconDownload size={14} />}
variant="light"
onClick={exportPDF}
data-test="daily-report-pdf"
>
PDF
</Button>
</Group>
</Group>
{filteredDoneCards.length === 0 ? (
<Text size="xs" c="dimmed">
Sin hechas en este dia.
</Text>
) : (
<ScrollArea style={{ maxHeight: 280 }} type="auto">
<Table verticalSpacing={4} fz="xs" highlightOnHover striped="even">
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 70 }}>#</Table.Th>
<Table.Th>Titulo</Table.Th>
<Table.Th>Solicitante</Table.Th>
<Table.Th>Asignado</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th style={{ width: 110 }}>Lead time</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredDoneCards.map((c) => (
<Table.Tr key={c.id}>
<Table.Td>
<Text size="xs" c="dimmed">
{String(c.seq_num).padStart(5, "0")}
</Text>
</Table.Td>
<Table.Td>
<UnstyledButton onClick={() => onJumpToCard?.(c.id)} style={{ textAlign: "left" }}>
<Text size="xs" fw={500} td="underline">
{c.title}
</Text>
</UnstyledButton>
</Table.Td>
<Table.Td>
<Text size="xs">{c.requester || "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{c.assignee_name || "—"}</Text>
</Table.Td>
<Table.Td>
<Group gap={2} wrap="wrap">
{(c.tags || []).slice(0, 3).map((t) => (
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
{t}
</Badge>
))}
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{formatDuration(c.lead_time_ms)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
)}
</MCard>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<MCard withBorder radius="md" p="sm">
<Group justify="space-between" mb={6}>
<Text fw={600} size="sm">
Movimientos por hora
</Text>
<Badge size="xs" variant="light">
{k.moves}
</Badge>
</Group>
{k.moves === 0 ? (
<Text size="xs" c="dimmed">
Sin movimientos.
</Text>
) : (
<BarChart
h={160}
data={hourlyChartData}
dataKey="hora"
series={[{ name: "movimientos", color: "blue.6" }]}
tickLine="y"
withTooltip
valueFormatter={(v: number) => String(v)}
/>
)}
</MCard>
<MCard withBorder radius="md" p="sm">
<Text fw={600} size="sm" mb={6}>
Tags trabajadas
</Text>
{data.tags_done.length === 0 ? (
<Text size="xs" c="dimmed">
Sin tags.
</Text>
) : (
<Group gap={4} wrap="wrap">
{data.tags_done.map((t) => (
<Badge key={t.name} variant="light" color={tagColor(t.name)} size="sm">
{t.name} · {t.count}
</Badge>
))}
</Group>
)}
</MCard>
</SimpleGrid>
{data.reopened_cards.length > 0 && (
<MCard withBorder radius="md" p="sm">
<Group gap={6} mb={6}>
<IconArrowBackUp size={14} color="var(--mantine-color-orange-6)" />
<Text fw={600} size="sm">
Reabiertas (Done otra)
</Text>
<Badge size="xs" variant="light" color="orange">
{data.reopened_cards.length}
</Badge>
</Group>
<Stack gap={4}>
{data.reopened_cards.map((r) => (
<Group key={r.card_id + r.ts} gap={6} wrap="nowrap" justify="space-between">
<UnstyledButton onClick={() => onJumpToCard?.(r.card_id)} style={{ minWidth: 0, flex: 1 }}>
<Text size="xs" truncate td="underline">
{r.title}
</Text>
</UnstyledButton>
<Text size="xs" c="dimmed">
{r.from_column} {r.to_column}
</Text>
{r.actor_name && (
<Badge size="xs" variant="light" color="cyan">
{r.actor_name}
</Badge>
)}
</Group>
))}
</Stack>
</MCard>
)}
{(data.deadlines.missed > 0 || data.deadlines.met > 0) && (
<MCard withBorder radius="md" p="sm">
<Group gap={6} mb={6}>
<IconHourglass size={14} />
<Text fw={600} size="sm">
Deadlines
</Text>
<Badge size="xs" variant="light" color="teal">
{data.deadlines.met} on-time
</Badge>
<Badge size="xs" variant="light" color="red">
{data.deadlines.missed} vencidos
</Badge>
</Group>
{data.deadlines.list.length > 0 && (
<Stack gap={4}>
{data.deadlines.list.map((d) => (
<Group key={d.card_id} gap={6} justify="space-between" wrap="nowrap">
<UnstyledButton onClick={() => onJumpToCard?.(d.card_id)} style={{ minWidth: 0, flex: 1 }}>
<Text size="xs" truncate td="underline">
{d.title}
</Text>
</UnstyledButton>
<Text size="xs" c="red">
+{formatDuration(d.late_ms)} tarde
</Text>
</Group>
))}
</Stack>
)}
</MCard>
)}
<MCard withBorder radius="md" p="sm">
<Group gap={6} mb={6}>
<IconTrendingUp size={14} />
<Text fw={600} size="sm">
Cards estancadas (al final del dia)
</Text>
<Badge size="xs" variant="light" color="orange">
{data.stale_cards.d7.length}d7
</Badge>
<Badge size="xs" variant="light" color="red">
{data.stale_cards.d14.length}d14
</Badge>
<Badge size="xs" variant="filled" color="red">
{data.stale_cards.d30.length}d30
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
<Box>
<Text size="xs" fw={500} c="orange" mb={4}>
7-13 dias
</Text>
<Stack gap={2}>
{data.stale_cards.d7.slice(0, 8).map((s) => (
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
<Text size="xs" truncate>
{s.title}{" "}
<Text span c="dimmed" size="xs">
· {s.column_name} · {s.days}d
</Text>
</Text>
</UnstyledButton>
))}
{data.stale_cards.d7.length === 0 && (
<Text size="xs" c="dimmed">
Ninguna.
</Text>
)}
</Stack>
</Box>
<Box>
<Text size="xs" fw={500} c="red" mb={4}>
14-29 dias
</Text>
<Stack gap={2}>
{data.stale_cards.d14.slice(0, 8).map((s) => (
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
<Text size="xs" truncate>
{s.title}{" "}
<Text span c="dimmed" size="xs">
· {s.column_name} · {s.days}d
</Text>
</Text>
</UnstyledButton>
))}
{data.stale_cards.d14.length === 0 && (
<Text size="xs" c="dimmed">
Ninguna.
</Text>
)}
</Stack>
</Box>
<Box>
<Text size="xs" fw={500} c="red.8" mb={4}>
30+ dias
</Text>
<Stack gap={2}>
{data.stale_cards.d30.slice(0, 8).map((s) => (
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
<Text size="xs" truncate fw={600}>
{s.title}{" "}
<Text span c="dimmed" size="xs" fw={400}>
· {s.column_name} · {s.days}d
</Text>
</Text>
</UnstyledButton>
))}
{data.stale_cards.d30.length === 0 && (
<Text size="xs" c="dimmed">
Ninguna.
</Text>
)}
</Stack>
</Box>
</SimpleGrid>
</MCard>
<Divider />
<Group gap={6} justify="space-between">
<Group gap={4}>
<IconClock size={14} />
<Text size="xs" c="dimmed">
TZ: {data.tz} · cards archivadas hoy: {data.archived_today}
</Text>
</Group>
</Group>
<Modal opened={settingsOpen} onClose={() => setSettingsOpen(false)} title="Prompt del agente diario" size="lg" zIndex={500}>
<Stack gap="sm">
<Text size="xs" c="dimmed">
Plantilla que el agente recibe junto al JSON del reporte. Compartida por todos los usuarios.
</Text>
<Textarea
autosize
minRows={6}
maxRows={20}
value={promptDraft}
onChange={(e) => setPromptDraft(e.currentTarget.value)}
data-test="daily-report-prompt"
/>
<Group justify="space-between">
<Button size="xs" variant="subtle" onClick={resetSettings}>
Restablecer por defecto
</Button>
<Group gap={6}>
<Button size="xs" variant="subtle" color="gray" onClick={() => setSettingsOpen(false)}>
Cancelar
</Button>
<Button size="xs" onClick={saveSettings} data-test="daily-report-prompt-save">
Guardar
</Button>
</Group>
</Group>
</Stack>
</Modal>
</Stack>
);
}
+82 -17
View File
@@ -1,8 +1,9 @@
import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import { Badge, Divider, Group, Loader, Stack, Table, Text, Timeline } from "@mantine/core";
import {
IconArrowsHorizontal,
IconCalendarDue,
IconCalendarOff,
IconCheck,
IconColumns3,
IconEdit,
IconLock,
@@ -16,11 +17,12 @@ import {
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import { cardHistory, listUsers } from "../api";
import type { Card, CardEvent, CardHistoryResponse, User } from "../types";
import type { Card, CardEvent, CardHistoryResponse, Column, User } from "../types";
import { formatDuration } from "./format";
interface Props {
card: Card;
columns?: Column[];
}
interface UnifiedEvent {
@@ -31,6 +33,7 @@ interface UnifiedEvent {
detail: string;
icon: React.ReactNode;
color: string;
doneColumn?: boolean;
}
function parsePayload(p: string): Record<string, unknown> {
@@ -67,10 +70,18 @@ function eventToUnified(e: CardEvent): UnifiedEvent {
}
}
export function HistoryModal({ card }: Props) {
export function HistoryModal({ card, columns = [] }: Props) {
const [data, setData] = useState<CardHistoryResponse | null>(null);
const [users, setUsers] = useState<User[]>([]);
const columnById = useMemo(() => {
const m = new Map<string, Column>();
for (const c of columns) m.set(c.id, c);
return m;
}, [columns]);
const isDoneColumn = (columnId: string) => columnById.get(columnId)?.is_done === true;
useEffect(() => {
cardHistory(card.id)
.then(setData)
@@ -91,14 +102,16 @@ export function HistoryModal({ card }: Props) {
const out: UnifiedEvent[] = [];
for (const e of data.events || []) out.push(eventToUnified(e));
for (const h of data.column_history || []) {
const done = isDoneColumn(h.column_id);
out.push({
id: "h_in_" + h.id,
ts: h.entered_at,
kind: "Mueve a columna",
kind: done ? "Hecho en columna" : "Mueve a columna",
actorID: h.actor_id,
detail: h.column_name || h.column_id,
icon: <IconArrowsHorizontal size={12} />,
color: "blue",
icon: done ? <IconCheck size={12} /> : <IconArrowsHorizontal size={12} />,
color: done ? "green" : "blue",
doneColumn: done,
});
}
for (const p of data.lock_periods || []) {
@@ -108,7 +121,7 @@ export function HistoryModal({ card }: Props) {
}
}
return out.sort((a, b) => a.ts.localeCompare(b.ts));
}, [data]);
}, [data, columnById]);
if (!data) {
return (
@@ -124,6 +137,26 @@ export function HistoryModal({ card }: Props) {
return <Text c="dimmed">Sin historial.</Text>;
}
// Per-column time stats: sum duration_ms by column_id from column_history.
// Currently-active entry (exited_at=null) gets duration_ms = now - entered_at.
const nowMs = Date.now();
const perColumnMs = new Map<string, { name: string; isDone: boolean; ms: number; visits: number }>();
for (const h of column_history) {
const dur = h.exited_at ? h.duration_ms : Math.max(0, nowMs - new Date(h.entered_at).getTime());
const key = h.column_id;
const prev = perColumnMs.get(key);
const meta = columnById.get(key);
perColumnMs.set(key, {
name: h.column_name || meta?.name || key,
isDone: meta?.is_done ?? false,
ms: (prev?.ms ?? 0) + dur,
visits: (prev?.visits ?? 0) + 1,
});
}
const perColumnRows = Array.from(perColumnMs.entries())
.map(([id, v]) => ({ id, ...v }))
.sort((a, b) => b.ms - a.ms);
const userLabel = (id: string | null): string => {
if (!id) return "";
const u = userById.get(id);
@@ -140,6 +173,7 @@ export function HistoryModal({ card }: Props) {
key={e.id}
bullet={e.icon}
color={e.color}
lineVariant={e.doneColumn ? "solid" : undefined}
title={
<Group gap={6} wrap="wrap">
<Text fw={500} size="sm">{e.kind}</Text>
@@ -163,16 +197,47 @@ export function HistoryModal({ card }: Props) {
<Divider />
<Group gap={6} align="center">
<IconColumns3 size={14} />
<Text fw={500} size="sm">Columnas visitadas</Text>
<Badge size="xs" variant="light" color="gray">{column_history.length}</Badge>
<IconLock size={14} color="var(--mantine-color-yellow-6)" />
<Badge size="xs" variant="light" color={total_locked_ms > 0 ? "yellow" : "gray"}>
{formatDuration(total_locked_ms)}
</Badge>
{currently_locked && <Badge size="xs" variant="filled" color="yellow">bloqueada</Badge>}
</Group>
<Stack gap={6}>
<Group gap={6} align="center" wrap="wrap">
<IconColumns3 size={14} />
<Text fw={500} size="sm">Tiempo por columna</Text>
<Badge size="xs" variant="light" color="gray">{column_history.length} entradas</Badge>
<Text size="xs" c="dimmed" ml="auto">
<IconLock size={11} style={{ verticalAlign: "middle" }} />{" "}
<Text span size="xs" fw={500} c={total_locked_ms > 0 ? "yellow" : "dimmed"}>
{formatDuration(total_locked_ms)}
</Text>{" "}
bloqueada{currently_locked ? " (en curso)" : ""}
</Text>
</Group>
{perColumnRows.length > 0 ? (
<Table withTableBorder withColumnBorders striped="even" verticalSpacing={4} fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Columna</Table.Th>
<Table.Th style={{ width: 60 }}>Visitas</Table.Th>
<Table.Th style={{ width: 130 }}>Tiempo total</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{perColumnRows.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<Group gap={4} wrap="nowrap">
{r.isDone && <IconCheck size={12} color="var(--mantine-color-green-6)" />}
<Text size="xs" fw={r.isDone ? 600 : 400}>{r.name}</Text>
</Group>
</Table.Td>
<Table.Td>{r.visits}</Table.Td>
<Table.Td>{formatDuration(r.ms)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text size="xs" c="dimmed">Sin movimientos entre columnas.</Text>
)}
</Stack>
</Stack>
);
}
+455
View File
@@ -0,0 +1,455 @@
import {
Anchor,
Badge,
Box,
Button,
Checkbox,
Group,
Image,
Loader,
Modal,
Select,
Stack,
Tabs,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
IconAlertTriangle,
IconBrandJira,
IconChecks,
IconDownload,
IconRefresh,
IconSearch,
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import * as api from "../api";
import type { JiraIssue } from "../api";
import type { Column } from "../types";
interface Props {
opened: boolean;
onClose: () => void;
columns: Column[];
// Called when imports or reconciles modified the board so the parent can
// refetch /api/board.
onMutated?: () => void;
}
// JiraModal is the admin hub for everything Jira-related: importing issues
// into kanban and verifying the kanban-column ↔ Jira-status mapping is in
// sync. The previous standalone "Importar de Jira" modal moved here as the
// "Importar" tab; the new "Comprobar columnas" tab surfaces drift detected
// by /api/jira/check-columns and offers a one-click fix per row.
export function JiraModal({ opened, onClose, columns, onMutated }: Props) {
const [tab, setTab] = useState<string | null>("import");
return (
<Modal
opened={opened}
onClose={onClose}
size="xl"
title={
<Group gap={8}>
<IconBrandJira size={18} />
<Text fw={600}>Jira</Text>
</Group>
}
>
<Tabs value={tab} onChange={setTab} keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="import" leftSection={<IconDownload size={14} />}>
Importar de Jira
</Tabs.Tab>
<Tabs.Tab value="check" leftSection={<IconChecks size={14} />}>
Comprobar columnas
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="import" pt="sm">
<ImportTab columns={columns} onImported={onMutated} />
</Tabs.Panel>
<Tabs.Panel value="check" pt="sm">
<CheckTab onReconciled={onMutated} />
</Tabs.Panel>
</Tabs>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Importar de Jira (kept verbatim from the old standalone modal, minus the
// Modal frame which lives in the parent now).
// ---------------------------------------------------------------------------
function ImportTab({ columns, onImported }: { columns: Column[]; onImported?: () => void }) {
const [issues, setIssues] = useState<JiraIssue[] | null>(null);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [showImported, setShowImported] = useState(false);
const [query, setQuery] = useState("");
const [fallbackColumn, setFallbackColumn] = useState<string>("");
const [boardId, setBoardId] = useState<number | null>(null);
const [projectKey, setProjectKey] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const reload = async () => {
setLoading(true);
setError(null);
try {
const r = await api.listJiraIssues({ includeImported: showImported, limit: 200 });
setIssues(r.issues);
setBoardId(r.board_id);
setProjectKey(r.project_key);
setSelected((prev) => {
const validKeys = new Set(r.issues.map((i) => i.key));
const next = new Set<string>();
for (const k of prev) if (validKeys.has(k)) next.add(k);
return next;
});
} catch (e) {
setError((e as Error).message);
setIssues([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showImported]);
useEffect(() => {
if (!fallbackColumn && columns.length > 0) {
const boardCol = columns.find((c) => c.location !== "sidebar") || columns[0];
setFallbackColumn(boardCol.id);
}
}, [columns, fallbackColumn]);
const visible = useMemo(() => {
if (!issues) return [];
const q = query.trim().toLowerCase();
if (!q) return issues;
return issues.filter(
(i) =>
i.key.toLowerCase().includes(q) ||
i.summary.toLowerCase().includes(q) ||
i.assignee.toLowerCase().includes(q) ||
i.status_name.toLowerCase().includes(q),
);
}, [issues, query]);
const toggle = (key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleAll = () => {
const importable = visible.filter((i) => !i.already_imported);
if (selected.size === importable.length && importable.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(importable.map((i) => i.key)));
}
};
const doImport = async () => {
if (selected.size === 0) return;
setImporting(true);
try {
const res = await api.importJiraIssues(Array.from(selected), fallbackColumn || undefined);
const ok = res.results.filter((r) => r.status === "imported").length;
const skip = res.results.filter((r) => r.status === "skipped").length;
const err = res.results.filter((r) => r.status === "error");
const msg = `${ok} importadas` + (skip > 0 ? ` · ${skip} omitidas` : "") + (err.length > 0 ? ` · ${err.length} con error` : "");
notifications.show({
color: err.length > 0 ? "yellow" : "green",
message: msg,
autoClose: 5000,
});
if (err.length > 0) console.warn("import errors", err);
setSelected(new Set());
await reload();
onImported?.();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setImporting(false);
}
};
const columnOptions = columns.map((c) => ({ value: c.id, label: c.name }));
return (
<Stack gap="sm">
<Text size="xs" c="dimmed">
{boardId ? `Board ${boardId} (${projectKey})` : "Cargando board..."}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
placeholder="Filtrar por key, titulo, asignado o status..."
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
leftSection={<IconSearch size={14} />}
style={{ flex: 1 }}
/>
<Tooltip label="Refrescar" withArrow>
<Button variant="default" onClick={reload} loading={loading} leftSection={<IconRefresh size={14} />}>
Refrescar
</Button>
</Tooltip>
</Group>
<Group justify="space-between" gap="xs">
<Checkbox
label="Mostrar issues ya importadas"
checked={showImported}
onChange={(e) => setShowImported(e.currentTarget.checked)}
/>
<Select
label="Columna fallback"
description="Para issues cuyo status no tiene mapping"
data={columnOptions}
value={fallbackColumn}
onChange={(v) => setFallbackColumn(v || "")}
allowDeselect={false}
w={260}
size="xs"
/>
</Group>
{error && <Text size="sm" c="red">{error}</Text>}
<Box style={{ maxHeight: 440, overflowY: "auto", border: "1px solid var(--mantine-color-default-border)", borderRadius: 4 }}>
{loading && !issues ? (
<Group justify="center" p="md"><Loader size="sm" /></Group>
) : visible.length === 0 ? (
<Text size="sm" c="dimmed" p="md" ta="center">
{issues?.length === 0 ? "No hay issues sin importar en el board." : "Ninguna issue coincide con el filtro."}
</Text>
) : (
<Stack gap={0}>
<Group p="xs" gap="xs" style={{ borderBottom: "1px solid var(--mantine-color-default-border)", background: "var(--mantine-color-default-hover)" }}>
<Checkbox
checked={selected.size > 0 && selected.size === visible.filter((i) => !i.already_imported).length}
indeterminate={selected.size > 0 && selected.size < visible.filter((i) => !i.already_imported).length}
onChange={toggleAll}
/>
<Text size="xs" fw={600} c="dimmed">{visible.length} issues · {selected.size} seleccionadas</Text>
</Group>
{visible.map((iss) => (
<Group key={iss.key} p="xs" gap="xs" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)", opacity: iss.already_imported ? 0.5 : 1 }}>
<Checkbox checked={selected.has(iss.key)} disabled={iss.already_imported} onChange={() => toggle(iss.key)} />
{iss.issue_type_icon && <Image src={iss.issue_type_icon} w={16} h={16} alt={iss.issue_type} />}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Anchor href={iss.url} target="_blank" rel="noopener noreferrer" size="sm" fw={600}>{iss.key}</Anchor>
<Text size="sm" truncate style={{ flex: 1 }}>{iss.summary}</Text>
</Group>
<Group gap={4} wrap="nowrap">
<Badge size="xs" variant="light" color={badgeColor(iss.status_name)}>{iss.status_name}</Badge>
<Badge size="xs" variant="outline">{iss.issue_type}</Badge>
{iss.assignee && <Text size="xs" c="dimmed">· {iss.assignee}</Text>}
{iss.already_imported && <Badge size="xs" color="gray">ya en kanban</Badge>}
{!iss.already_imported && !iss.mapped_column_id && <Badge size="xs" color="orange" variant="light">sin mapping (usa fallback)</Badge>}
</Group>
</Stack>
</Group>
))}
</Stack>
)}
</Box>
<Group justify="flex-end" gap="xs">
<Button onClick={doImport} disabled={selected.size === 0 || importing} loading={importing}>
Importar {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</Group>
</Stack>
);
}
// ---------------------------------------------------------------------------
// Comprobar columnas: detects drift between kanban column ↔ Jira status.
// ---------------------------------------------------------------------------
function CheckTab({ onReconciled }: { onReconciled?: () => void }) {
const [data, setData] = useState<api.JiraCheckResponse | null>(null);
const [loading, setLoading] = useState(false);
const [fixing, setFixing] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [onlyMismatch, setOnlyMismatch] = useState(true);
const [error, setError] = useState<string | null>(null);
const reload = async () => {
setLoading(true);
setError(null);
try {
const r = await api.checkJiraColumns();
setData(r);
// After a refresh, drop selections that no longer apply.
setSelected((prev) => {
const validIds = new Set(r.rows.filter((x) => x.mismatch).map((x) => x.card_id));
const next = new Set<string>();
for (const id of prev) if (validIds.has(id)) next.add(id);
return next;
});
} catch (e) {
setError((e as Error).message);
setData(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
}, []);
const visible = useMemo(() => {
if (!data) return [];
return onlyMismatch ? data.rows.filter((r) => r.mismatch) : data.rows;
}, [data, onlyMismatch]);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
const fixable = visible.filter((r) => r.mismatch).map((r) => r.card_id);
if (selected.size === fixable.length && fixable.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(fixable));
}
};
const doFix = async () => {
if (selected.size === 0) return;
setFixing(true);
try {
const res = await api.reconcileJiraColumns(Array.from(selected));
const ok = res.results.filter((r) => r.status === "fixed").length;
const skip = res.results.filter((r) => r.status === "skipped").length;
const err = res.results.filter((r) => r.status === "error");
const msg = `${ok} sincronizadas` + (skip > 0 ? ` · ${skip} omitidas` : "") + (err.length > 0 ? ` · ${err.length} con error` : "");
notifications.show({
color: err.length > 0 ? "yellow" : "green",
message: msg,
autoClose: 5000,
});
if (err.length > 0) console.warn("reconcile errors", err);
setSelected(new Set());
await reload();
onReconciled?.();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setFixing(false);
}
};
return (
<Stack gap="sm">
<Text size="xs" c="dimmed">
Compara la columna kanban de cada card con el status actual de su Jira issue. Lo hace
consultando Jira en vivo (1 request por card) puede tardar varios segundos con
muchas cards.
</Text>
{data && (
<Group gap="xs">
<Badge color="green" variant="light">{data.in_sync} en sync</Badge>
{data.mismatches > 0 && (
<Badge color="red" variant="filled" leftSection={<IconAlertTriangle size={12} />}>
{data.mismatches} desincronizadas
</Badge>
)}
<Badge color="gray" variant="outline">{data.total} totales</Badge>
</Group>
)}
<Group gap="xs" justify="space-between">
<Checkbox
label="Solo mostrar desincronizadas"
checked={onlyMismatch}
onChange={(e) => setOnlyMismatch(e.currentTarget.checked)}
/>
<Button variant="default" onClick={reload} loading={loading} leftSection={<IconRefresh size={14} />}>
Re-comprobar
</Button>
</Group>
{error && <Text size="sm" c="red">{error}</Text>}
<Box style={{ maxHeight: 440, overflowY: "auto", border: "1px solid var(--mantine-color-default-border)", borderRadius: 4 }}>
{loading && !data ? (
<Group justify="center" p="md"><Loader size="sm" /></Group>
) : visible.length === 0 ? (
<Text size="sm" c="dimmed" p="md" ta="center">
{data && data.mismatches === 0
? "Todas las cards estan en su columna correcta. ✅"
: "No hay rows que mostrar con el filtro actual."}
</Text>
) : (
<Stack gap={0}>
<Group p="xs" gap="xs" style={{ borderBottom: "1px solid var(--mantine-color-default-border)", background: "var(--mantine-color-default-hover)" }}>
<Checkbox
checked={selected.size > 0 && selected.size === visible.filter((r) => r.mismatch).length}
indeterminate={selected.size > 0 && selected.size < visible.filter((r) => r.mismatch).length}
onChange={toggleAll}
/>
<Text size="xs" fw={600} c="dimmed">
{visible.length} rows · {selected.size} seleccionadas para sync
</Text>
</Group>
{visible.map((row) => (
<Group key={row.card_id} p="xs" gap="xs" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
<Checkbox checked={selected.has(row.card_id)} disabled={!row.mismatch} onChange={() => toggle(row.card_id)} />
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Anchor href={row.issue_url} target="_blank" rel="noopener noreferrer" size="sm" fw={600}>{row.jira_key}</Anchor>
<Text size="sm" truncate style={{ flex: 1 }}>{row.title}</Text>
</Group>
<Group gap={4} wrap="nowrap">
<Badge size="xs" variant="light" color="blue">kanban: {row.kanban_column_name}</Badge>
<Text size="xs" c="dimmed"> esperado Jira: <b>{row.expected_jira_status || "(sin mapeo)"}</b></Text>
<Badge size="xs" variant="light" color={row.mismatch ? "red" : "green"}>
jira: {row.jira_status_name}
</Badge>
</Group>
</Stack>
</Group>
))}
</Stack>
)}
</Box>
<Group justify="space-between" gap="xs">
<Text size="xs" c="dimmed">Fix = transicionar Jira al status que matchea la columna kanban (kanban gana)</Text>
<Button color="orange" onClick={doFix} disabled={selected.size === 0 || fixing} loading={fixing}>
Sincronizar {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</Group>
</Stack>
);
}
// ---------------------------------------------------------------------------
function badgeColor(status: string): string {
const s = status.toLowerCase();
if (s.includes("done") || s.includes("hecho") || s.includes("closed")) return "green";
if (s.includes("progress") || s.includes("doing")) return "blue";
if (s.includes("implementado") || s.includes("review")) return "violet";
if (s.includes("creado") || s.includes("backlog") || s.includes("nuevo")) return "gray";
return "yellow";
}
@@ -0,0 +1,178 @@
import { Anchor, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
import { IconBrandJira, IconAlertCircle } from "@tabler/icons-react";
import { CSSProperties, useEffect, useState } from "react";
import * as api from "../api";
import type { CardJiraSyncState } from "../api";
import { formatDateTimeShort } from "./format";
// Adaptive polling: 5s steady-state, 1s while the card is mid-sync. The fast
// cadence catches the yellow → green transition right after a column drag;
// the slow cadence keeps the per-card load manageable when the board is idle.
const POLL_MS_STEADY = 5000;
const POLL_MS_INFLIGHT = 1000;
type Tone = "gray" | "yellow" | "green" | "red";
function tone(state: CardJiraSyncState): Tone {
if (state.inflight) return "yellow";
if (state.last_error) return "red";
if (state.jira_key) return "green";
return "gray";
}
const TONE_COLOR: Record<Tone, string> = {
gray: "var(--mantine-color-gray-5)",
yellow: "var(--mantine-color-yellow-5)",
green: "var(--mantine-color-green-5)",
red: "var(--mantine-color-red-6)",
};
const TONE_LABEL: Record<Tone, string> = {
gray: "Sin sincronizar con Jira",
yellow: "Sincronizando...",
green: "Sincronizada con Jira",
red: "Error de sincronizacion",
};
interface Props {
cardId: string;
// Pollen-down so the parent can refresh when needed (e.g. after a move
// animation finishes) without waiting for the next tick.
refreshTick?: number;
}
export function JiraSyncIndicator({ cardId, refreshTick }: Props) {
const [state, setState] = useState<CardJiraSyncState | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const schedule = (ms: number) => {
if (cancelled) return;
if (timer) clearTimeout(timer);
timer = setTimeout(load, ms);
};
const load = async () => {
try {
const s = await api.getCardJiraSync(cardId);
if (cancelled) return;
setState(s);
setErr(null);
// Adaptive cadence: fast while the dispatcher is actively processing
// this card, slow otherwise. Re-arms on every fetch so the moment
// inflight flips off we drop back to the steady cadence.
schedule(s.inflight ? POLL_MS_INFLIGHT : POLL_MS_STEADY);
} catch (e) {
if (cancelled) return;
setErr((e as Error).message);
schedule(POLL_MS_STEADY);
}
};
// Window event fired by the App's drag-end handler so the indicator
// refetches immediately after a move (and so the user sees yellow within
// ~200ms instead of waiting up to POLL_MS_STEADY).
const onMoved = (e: Event) => {
const detail = (e as CustomEvent).detail as { cardId?: string } | undefined;
if (detail?.cardId === cardId) {
schedule(150);
}
};
window.addEventListener("kanban-card-moved", onMoved);
load();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
window.removeEventListener("kanban-card-moved", onMoved);
};
}, [cardId, refreshTick]);
if (err && !state) {
return (
<Box title={err} style={dotStyle("var(--mantine-color-gray-3)")} aria-label="Jira sync state unavailable" />
);
}
if (!state) {
// Initial render — fade in a placeholder dot so the layout does not shift
// when the fetch resolves.
return <Box style={dotStyle("var(--mantine-color-gray-2)")} aria-label="Cargando estado Jira" />;
}
const t = tone(state);
return (
<HoverCard width={300} shadow="md" openDelay={150} closeDelay={120} withinPortal>
<HoverCard.Target>
<Box
role="status"
aria-label={TONE_LABEL[t]}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
style={dotStyle(TONE_COLOR[t])}
/>
</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<Stack gap={6}>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
<IconBrandJira size={14} />
<Text size="sm" fw={600}>{TONE_LABEL[t]}</Text>
</Group>
{state.issue_url && (
<Anchor
size="xs"
href={state.issue_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Abrir en Jira
</Anchor>
)}
</Group>
{state.jira_key && (
<Text size="xs">
<Text component="span" c="dimmed">Issue:</Text>{" "}
<Text component="span" fw={600}>{state.jira_key}</Text>
</Text>
)}
{state.last_status && (
<Text size="xs">
<Text component="span" c="dimmed">Status:</Text>{" "}
<Text component="span">{state.last_status}</Text>
</Text>
)}
{state.last_sync_at && (
<Text size="xs">
<Text component="span" c="dimmed">Ultimo sync:</Text>{" "}
<Text component="span">{formatDateTimeShort(state.last_sync_at)}</Text>
</Text>
)}
{state.last_error && (
<Group gap={6} wrap="nowrap" align="flex-start">
<IconAlertCircle size={14} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red" style={{ wordBreak: "break-word" }}>{state.last_error}</Text>
</Group>
)}
{!state.jira_key && (
<Text size="xs" c="dimmed">
La card todavia no se ha empujado a Jira. Editala o muevela para
disparar el sync, o usa la opcion "Importar de Jira" si ya existe
alli.
</Text>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}
function dotStyle(color: string): CSSProperties {
return {
width: 10,
height: 10,
borderRadius: "50%",
background: color,
cursor: "default",
boxShadow: "0 0 0 2px var(--mantine-color-body)",
transition: "background 120ms ease",
};
}
+291 -277
View File
@@ -15,9 +15,11 @@ import {
Tooltip,
} from "@mantine/core";
import {
IconArchive,
IconCalendarDue,
IconCheck,
IconClock,
IconCopy,
IconDotsVertical,
IconEdit,
IconGripVertical,
@@ -31,23 +33,26 @@ import {
IconUserCircle,
} from "@tabler/icons-react";
import { DatePickerInput } from "@mantine/dates";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Card, CardColor, User } from "../types";
import { colorBg, colorBorder, tagColor } from "./colors";
import { ColorPickerGrid } from "./ColorPickerGrid";
import { formatDateTimeShort, formatDuration } from "./format";
import { JiraSyncIndicator } from "./JiraSyncIndicator";
interface Props {
card: Card;
now: number;
onDelete: (id: string) => void;
onEdit: (card: Card) => void;
onDuplicate?: (id: string) => void;
onChangeColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
onToggleLock: (id: string, locked: boolean) => void;
onAssign: (id: string, assignee_id: string | null) => void;
onSetDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
onArchive?: (id: string) => void;
requesterOptions?: string[];
onOpenCustomColor?: (cardId: string, current: string) => void;
activeSticker?: string | null;
@@ -58,69 +63,107 @@ interface Props {
users: User[];
assignee?: User;
inDoneColumn?: boolean;
columnOverdue?: boolean;
isOverlay?: boolean;
highlight?: boolean;
}
function KanbanCardImpl({
// PERF debug helpers (gated): cuentan renders por capa durante drag.
function _probeRender() {
const w = window as unknown as { _cardRenderProbe?: boolean; _cardRenderCount?: number };
if (w._cardRenderProbe) w._cardRenderCount = (w._cardRenderCount || 0) + 1;
}
function _probeBodyRender() {
const w = window as unknown as { _cardRenderProbe?: boolean; _cardBodyRenderCount?: number };
if (w._cardRenderProbe) w._cardBodyRenderCount = (w._cardBodyRenderCount || 0) + 1;
}
// KanbanCardBody — contiene Stack + sticker overlay + states locales (popovers,
// requesterDraft). Memoizado para que dnd-kit re-render del wrapper exterior
// (provocado por useSortable cada pointermove) NO rebote a este tree.
interface CardBodyProps {
card: Card;
isDone: boolean;
isOverlay?: boolean;
highlight?: boolean;
activeSticker?: string | null;
cardElRef: React.MutableRefObject<HTMLElement | null>;
now: number;
users: User[];
assignee?: User;
requesterOptions?: string[];
menuOpen: boolean;
setMenuOpen: (v: boolean | ((p: boolean) => boolean)) => void;
onDelete: (id: string) => void;
onEdit: (card: Card) => void;
onDuplicate?: (id: string) => void;
onChangeColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
onToggleLock: (id: string, locked: boolean) => void;
onAssign: (id: string, assignee_id: string | null) => void;
onSetDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
onArchive?: (id: string) => void;
onOpenCustomColor?: (cardId: string, current: string) => void;
onRemoveSticker?: (cardId: string, index: number) => void;
onMoveSticker?: (cardId: string, index: number, x: number, y: number) => void;
onCommitSticker?: (cardId: string) => void;
}
const KanbanCardBody = memo(function KanbanCardBody({
card,
isDone,
isOverlay,
activeSticker,
cardElRef,
now,
users,
assignee,
requesterOptions,
menuOpen,
setMenuOpen,
onDelete,
onEdit,
onDuplicate,
onChangeColor,
onShowHistory,
onToggleLock,
onAssign,
onSetDeadline,
onSetRequester,
requesterOptions,
onArchive,
onOpenCustomColor,
activeSticker,
onAddSticker,
onRemoveSticker,
onMoveSticker,
onCommitSticker,
users,
assignee,
inDoneColumn,
isOverlay,
highlight,
}: Props) {
const isDone = inDoneColumn || !!card.completed_at;
}: CardBodyProps) {
_probeBodyRender();
const stickerMode = !!activeSticker;
const [colorPopOpen, setColorPopOpen] = useState(false);
const [assigneePopOpen, setAssigneePopOpen] = useState(false);
const [requesterPopOpen, setRequesterPopOpen] = useState(false);
const [deadlinePopOpen, setDeadlinePopOpen] = useState(false);
const [requesterDraft, setRequesterDraft] = useState(card.requester || "");
const [menuOpen, setMenuOpen] = useState(false);
const cardElRef = useRef<HTMLElement | null>(null);
const draggingStickerRef = useRef<number | null>(null);
const stickerMode = !!activeSticker;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: card.id,
data: { type: "card", columnId: card.column_id, locked: card.locked },
disabled: stickerMode,
});
const setCardRef = useCallback((el: HTMLElement | null) => {
cardElRef.current = el;
setNodeRef(el);
}, [setNodeRef]);
useEffect(() => {
if (highlight && cardElRef.current) {
cardElRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [highlight]);
const onCardClickAddSticker = (e: React.MouseEvent<HTMLDivElement>) => {
if (!stickerMode || !onAddSticker || isOverlay) return;
if ((e.target as HTMLElement).closest("[data-sticker-overlay]")) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
onAddSticker(card.id, Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y)));
};
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
const liveMs = Math.max(0, now - enteredAt);
const deadlineAt = card.deadline ? new Date(card.deadline).getTime() : 0;
const deadlineRemainingMs = deadlineAt ? deadlineAt - now : 0;
const overdue = deadlineAt ? deadlineRemainingMs < 0 : false;
const createdAtMs = card.created_at ? new Date(card.created_at).getTime() : 0;
const deadlineTotalMs = deadlineAt && createdAtMs ? deadlineAt - createdAtMs : 0;
const deadlineRatio = deadlineTotalMs > 0 ? deadlineRemainingMs / deadlineTotalMs : 0;
let dlColor: string = "blue";
let dlVariant: "light" | "filled" = "light";
if (overdue) { dlColor = "red.9"; dlVariant = "filled"; }
else if (deadlineRatio < 0.1) { dlColor = "red"; dlVariant = "filled"; }
else if (deadlineRatio < 0.5) { dlColor = "yellow"; dlVariant = "light"; }
const lockedAt = card.locked_at ? new Date(card.locked_at).getTime() : 0;
const lockedMs = card.locked && lockedAt ? Math.max(0, now - lockedAt) : 0;
const createdAt = card.created_at ? new Date(card.created_at).getTime() : 0;
const completedAt = card.completed_at ? new Date(card.completed_at).getTime() : 0;
const totalDoneMs = isDone && createdAt && completedAt ? Math.max(0, completedAt - createdAt) : 0;
const startStickerDrag = (index: number) => (e: React.PointerEvent<HTMLSpanElement>) => {
if (!stickerMode || isOverlay || !onMoveSticker) return;
@@ -159,68 +202,18 @@ function KanbanCardImpl({
onRemoveSticker?.(card.id, index);
};
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color),
borderColor: highlight ? "var(--mantine-color-blue-5)" : card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
borderWidth: highlight || card.locked ? 2 : 1,
boxShadow: highlight ? "0 0 0 3px var(--mantine-color-blue-4)" : undefined,
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
};
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
const liveMs = Math.max(0, now - enteredAt);
const deadlineAt = card.deadline ? new Date(card.deadline).getTime() : 0;
const deadlineRemainingMs = deadlineAt ? deadlineAt - now : 0;
const overdue = deadlineAt ? deadlineRemainingMs < 0 : false;
const createdAtMs = card.created_at ? new Date(card.created_at).getTime() : 0;
const deadlineTotalMs = deadlineAt && createdAtMs ? deadlineAt - createdAtMs : 0;
const deadlineRatio = deadlineTotalMs > 0 ? deadlineRemainingMs / deadlineTotalMs : 0;
let dlColor: string = "blue";
let dlVariant: "light" | "filled" = "light";
if (overdue) { dlColor = "red.9"; dlVariant = "filled"; }
else if (deadlineRatio < 0.1) { dlColor = "red"; dlVariant = "filled"; }
else if (deadlineRatio < 0.5) { dlColor = "yellow"; dlVariant = "light"; }
const lockedAt = card.locked_at ? new Date(card.locked_at).getTime() : 0;
const lockedMs = card.locked && lockedAt ? Math.max(0, now - lockedAt) : 0;
const createdAt = card.created_at ? new Date(card.created_at).getTime() : 0;
const completedAt = card.completed_at ? new Date(card.completed_at).getTime() : 0;
const totalDoneMs = isDone && createdAt && completedAt ? Math.max(0, completedAt - createdAt) : 0;
const onContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setMenuOpen(true);
};
const menuItems = (
const menuItems = !menuOpen ? null : (
<>
<Menu.Label>Acciones</Menu.Label>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={() => {
setMenuOpen(false);
onEdit(card);
}}
>
Editar
</Menu.Item>
<Popover
opened={colorPopOpen}
onChange={setColorPopOpen}
position="right-start"
withArrow
shadow="md"
>
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => { setMenuOpen(false); onEdit(card); }}>Editar</Menu.Item>
{onDuplicate && (
<Menu.Item leftSection={<IconCopy size={14} />} onClick={() => { setMenuOpen(false); onDuplicate(card.id); }}>Duplicar</Menu.Item>
)}
<Popover opened={colorPopOpen} onChange={setColorPopOpen} position="right-start" withArrow shadow="md">
<Popover.Target>
<Menu.Item
leftSection={<IconPalette size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setColorPopOpen((v) => !v);
}}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setColorPopOpen((v) => !v); }}
closeMenuOnClick={false}
>
Color
@@ -234,22 +227,11 @@ function KanbanCardImpl({
/>
</Popover.Dropdown>
</Popover>
<Popover
opened={assigneePopOpen}
onChange={setAssigneePopOpen}
position="right-start"
withArrow
shadow="md"
withinPortal={false}
>
<Popover opened={assigneePopOpen} onChange={setAssigneePopOpen} position="right-start" withArrow shadow="md" withinPortal={false}>
<Popover.Target>
<Menu.Item
leftSection={<IconUserCircle size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAssigneePopOpen((v) => !v);
}}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setAssigneePopOpen((v) => !v); }}
closeMenuOnClick={false}
>
Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."}
@@ -259,11 +241,7 @@ function KanbanCardImpl({
<Select
placeholder="Sin asignar"
value={card.assignee_id ?? null}
onChange={(v) => {
onAssign(card.id, v);
setAssigneePopOpen(false);
setMenuOpen(false);
}}
onChange={(v) => { onAssign(card.id, v); setAssigneePopOpen(false); setMenuOpen(false); }}
data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
clearable
searchable
@@ -272,23 +250,11 @@ function KanbanCardImpl({
/>
</Popover.Dropdown>
</Popover>
<Popover
opened={requesterPopOpen}
onChange={setRequesterPopOpen}
position="right-start"
withArrow
shadow="md"
withinPortal={false}
>
<Popover opened={requesterPopOpen} onChange={setRequesterPopOpen} position="right-start" withArrow shadow="md" withinPortal={false}>
<Popover.Target>
<Menu.Item
leftSection={<IconUserSquare size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setRequesterDraft(card.requester || "");
setRequesterPopOpen((v) => !v);
}}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setRequesterDraft(card.requester || ""); setRequesterPopOpen((v) => !v); }}
closeMenuOnClick={false}
>
Solicitante {card.requester ? `(${card.requester})` : "..."}
@@ -312,51 +278,24 @@ function KanbanCardImpl({
setRequesterPopOpen(false);
}
}}
onOptionSubmit={(v) => {
setRequesterDraft(v);
onSetRequester?.(card.id, v);
setRequesterPopOpen(false);
setMenuOpen(false);
}}
onOptionSubmit={(v) => { setRequesterDraft(v); onSetRequester?.(card.id, v); setRequesterPopOpen(false); setMenuOpen(false); }}
/>
</Popover.Dropdown>
</Popover>
<Menu.Item
leftSection={card.locked ? <IconLockOpen size={14} /> : <IconLock size={14} />}
color={card.locked ? "yellow" : undefined}
onClick={() => {
setMenuOpen(false);
onToggleLock(card.id, !card.locked);
}}
onClick={() => { setMenuOpen(false); onToggleLock(card.id, !card.locked); }}
>
{card.locked ? "Desbloquear" : "Bloquear"}
</Menu.Item>
<Menu.Item
leftSection={<IconHistory size={14} />}
onClick={() => {
setMenuOpen(false);
onShowHistory(card);
}}
>
Historial
</Menu.Item>
<Menu.Item leftSection={<IconHistory size={14} />} onClick={() => { setMenuOpen(false); onShowHistory(card); }}>Historial</Menu.Item>
{onSetDeadline && (
<Popover
opened={deadlinePopOpen}
onChange={setDeadlinePopOpen}
position="right-start"
withArrow
shadow="md"
withinPortal={false}
>
<Popover opened={deadlinePopOpen} onChange={setDeadlinePopOpen} position="right-start" withArrow shadow="md" withinPortal={false}>
<Popover.Target>
<Menu.Item
leftSection={<IconCalendarDue size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDeadlinePopOpen((v) => !v);
}}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setDeadlinePopOpen((v) => !v); }}
closeMenuOnClick={false}
>
{card.deadline ? `Deadline (${card.deadline.slice(0, 10)})` : "Deadline..."}
@@ -379,17 +318,7 @@ function KanbanCardImpl({
/>
{card.deadline && (
<Tooltip label="Quitar deadline" withArrow>
<ActionIcon
size="sm"
variant="subtle"
color="red"
mt={6}
onClick={() => {
onSetDeadline(card.id, null);
setDeadlinePopOpen(false);
setMenuOpen(false);
}}
>
<ActionIcon size="sm" variant="subtle" color="red" mt={6} onClick={() => { onSetDeadline(card.id, null); setDeadlinePopOpen(false); setMenuOpen(false); }}>
<IconTrash size={12} />
</ActionIcon>
</Tooltip>
@@ -397,90 +326,52 @@ function KanbanCardImpl({
</Popover.Dropdown>
</Popover>
)}
{isDone && onArchive && (
<Menu.Item
leftSection={<IconArchive size={14} />}
color="teal"
onClick={() => { setMenuOpen(false); onArchive(card.id); }}
>
Archivar
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={() => {
setMenuOpen(false);
onDelete(card.id);
}}
>
Borrar
</Menu.Item>
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => { setMenuOpen(false); onDelete(card.id); }}>Borrar</Menu.Item>
</>
);
return (
<Paper
ref={setCardRef}
style={{ ...style, position: "relative", cursor: stickerMode ? "copy" : "grab", touchAction: "none" }}
withBorder
p="xs"
shadow={isOverlay ? "lg" : "xs"}
radius="md"
onContextMenu={onContextMenu}
onClick={onCardClickAddSticker}
onDoubleClick={(e) => {
e.stopPropagation();
onEdit(card);
}}
{...attributes}
{...(stickerMode ? {} : listeners)}
>
<>
<Stack gap={6} style={{ position: "relative", zIndex: 1, pointerEvents: stickerMode ? "none" : undefined }}>
<Group justify="space-between" gap={4} wrap="nowrap" align="flex-start">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }} align="flex-start">
<IconGripVertical
size={14}
color="var(--mantine-color-dark-2)"
style={{ flexShrink: 0, marginTop: 4 }}
/>
<IconGripVertical size={14} color="var(--mantine-color-dark-2)" style={{ flexShrink: 0, marginTop: 4 }} />
{card.locked && (
<Tooltip label="Bloqueada" withArrow>
<IconLock
size={14}
color="var(--mantine-color-yellow-6)"
style={{ flexShrink: 0, marginTop: 4 }}
/>
<IconLock size={14} color="var(--mantine-color-yellow-6)" style={{ flexShrink: 0, marginTop: 4 }} />
</Tooltip>
)}
<Text
size="sm"
fw={500}
style={{
flex: 1,
wordBreak: "break-word",
whiteSpace: "normal",
textDecoration: isDone ? "line-through" : "none",
opacity: isDone ? 0.7 : 1,
}}
style={{ flex: 1, wordBreak: "break-word", whiteSpace: "normal", textDecoration: isDone ? "line-through" : "none", opacity: isDone ? 0.7 : 1 }}
>
{card.title}
</Text>
</Group>
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label="Acciones"
style={{ flexShrink: 0 }}
onPointerDown={(e) => e.stopPropagation()}
>
<IconDotsVertical size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown
onDoubleClick={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onContextMenu={(e) => e.stopPropagation()}
>
{menuItems}
</Menu.Dropdown>
</Menu>
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" onPointerDown={(e) => e.stopPropagation()}>
<IconDotsVertical size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
{menuItems}
</Menu.Dropdown>
</Menu>
<JiraSyncIndicator cardId={card.id} />
</Stack>
</Group>
{(card.requester || assignee) && (
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
@@ -500,83 +391,53 @@ function KanbanCardImpl({
<Avatar size={18} radius="xl" color={assignee.color || "blue"} style={{ flexShrink: 0 }}>
{(assignee.display_name || assignee.username).slice(0, 2).toUpperCase()}
</Avatar>
<Text size="xs" c="dimmed" truncate>
{assignee.display_name || assignee.username}
</Text>
<Text size="xs" c="dimmed" truncate>{assignee.display_name || assignee.username}</Text>
</>
)}
</Group>
)}
{card.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{card.description}
</Text>
<Text size="xs" c="dimmed" lineClamp={3}>{card.description}</Text>
)}
{card.tags && card.tags.length > 0 && (
<Group gap={4} wrap="wrap">
{card.tags.map((t) => (
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
{t}
</Badge>
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">{t}</Badge>
))}
</Group>
)}
<Group gap={4} wrap="wrap">
{card.locked && (
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
{formatDuration(lockedMs)}
</Badge>
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>{formatDuration(lockedMs)}</Badge>
)}
{!card.locked && isDone && card.completed_at ? (
<>
<Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}>
{formatDateTimeShort(card.completed_at)}
</Badge>
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
Total: {formatDuration(totalDoneMs)}
</Badge>
<Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}>{formatDateTimeShort(card.completed_at)}</Badge>
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>Total: {formatDuration(totalDoneMs)}</Badge>
{card.total_locked_ms > 0 && (
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
{formatDuration(card.total_locked_ms)}
</Badge>
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>{formatDuration(card.total_locked_ms)}</Badge>
)}
</>
) : !card.locked ? (
card.deadline ? (
<Tooltip label={`Vence: ${formatDateTimeShort(card.deadline)}`} withArrow>
<Badge
size="xs"
variant={dlVariant}
color={dlColor}
leftSection={<IconHourglass size={10} />}
>
<Badge size="xs" variant={dlVariant} color={dlColor} leftSection={<IconHourglass size={10} />}>
{overdue ? `-${formatDuration(-deadlineRemainingMs)}` : formatDuration(deadlineRemainingMs)}
</Badge>
</Tooltip>
) : (
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
{formatDuration(liveMs)}
</Badge>
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>{formatDuration(liveMs)}</Badge>
)
) : null}
</Group>
{card.seq_num > 0 && (
<Text size="xs" c="dimmed" style={{ marginTop: -2 }}>
#{String(card.seq_num).padStart(5, "0")}
</Text>
<Text size="xs" c="dimmed" style={{ marginTop: -2 }}>#{String(card.seq_num).padStart(5, "0")}</Text>
)}
</Stack>
{card.stickers && card.stickers.length > 0 && (
<div
data-sticker-overlay
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
overflow: "hidden",
borderRadius: "inherit",
zIndex: 0,
}}
style={{ position: "absolute", inset: 0, pointerEvents: "none", overflow: "hidden", borderRadius: "inherit", zIndex: 0 }}
>
{card.stickers.map((s, i) => (
<span
@@ -603,6 +464,159 @@ function KanbanCardImpl({
))}
</div>
)}
</>
);
});
function KanbanCardImpl({
card,
now,
onDelete,
onEdit,
onDuplicate,
onChangeColor,
onShowHistory,
onToggleLock,
onAssign,
onSetDeadline,
onSetRequester,
onArchive,
requesterOptions,
onOpenCustomColor,
activeSticker,
onAddSticker,
onRemoveSticker,
onMoveSticker,
onCommitSticker,
users,
assignee,
inDoneColumn,
columnOverdue,
isOverlay,
highlight,
}: Props) {
_probeRender();
const isDone = inDoneColumn || !!card.completed_at;
const [menuOpen, setMenuOpen] = useState(false);
const cardElRef = useRef<HTMLElement | null>(null);
const stickerMode = !!activeSticker;
// Memo: useSortable es sensible a la identidad del objeto `data`. Si lo
// re-creamos cada render, el setNodeRef interno se vuelve inestable y
// dispara loops por useMergedRef de Mantine (Paper). Issue: maximum
// update depth visto durante drag.
const sortableData = useMemo(
() => ({ type: "card" as const, columnId: card.column_id, locked: card.locked }),
[card.column_id, card.locked]
);
// Perf: disable layout animations. dnd-kit's default animates the slide of
// non-dragged items into their new sort position via an FLIP-like loop that
// re-runs useSortable on every pointermove for ALL cards in the
// SortableContext. With dozens of cards that drops frames hard (p95>=80ms).
// Disabling animations keeps the visual shift driven only by the active
// card's transform.
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: card.id,
data: sortableData,
disabled: stickerMode,
animateLayoutChanges: () => false,
});
const setCardRef = useCallback((el: HTMLElement | null) => {
cardElRef.current = el;
setNodeRef(el);
}, [setNodeRef]);
useEffect(() => {
if (highlight && cardElRef.current) {
cardElRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [highlight]);
const onCardClickAddSticker = (e: React.MouseEvent<HTMLDivElement>) => {
if (!stickerMode || !onAddSticker || isOverlay) return;
if ((e.target as HTMLElement).closest("[data-sticker-overlay]")) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
onAddSticker(card.id, Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y)));
};
const borderColorPicked = highlight
? "var(--mantine-color-blue-5)"
: columnOverdue
? "var(--mantine-color-red-6)"
: card.locked
? "var(--mantine-color-yellow-6)"
: colorBorder(card.color);
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color),
borderColor: borderColorPicked,
borderWidth: highlight || card.locked || columnOverdue ? 2 : 1,
boxShadow: highlight
? "0 0 0 3px var(--mantine-color-blue-4)"
: columnOverdue
? "0 0 0 2px var(--mantine-color-red-3)"
: undefined,
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
};
const onContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setMenuOpen(true);
};
return (
<Paper
ref={setCardRef}
style={{ ...style, position: "relative", cursor: stickerMode ? "copy" : "grab", touchAction: "none" }}
withBorder
p="xs"
shadow={isOverlay ? "lg" : "xs"}
radius="md"
data-card-id={card.id}
data-column-overdue={columnOverdue ? "true" : "false"}
data-locked={card.locked ? "true" : "false"}
onContextMenu={onContextMenu}
onClick={onCardClickAddSticker}
onDoubleClick={(e) => {
e.stopPropagation();
onEdit(card);
}}
{...attributes}
{...(stickerMode ? {} : listeners)}
>
<KanbanCardBody
card={card}
isDone={isDone}
isOverlay={isOverlay}
highlight={highlight}
activeSticker={activeSticker}
cardElRef={cardElRef}
now={now}
users={users}
assignee={assignee}
requesterOptions={requesterOptions}
menuOpen={menuOpen}
setMenuOpen={setMenuOpen}
onDelete={onDelete}
onEdit={onEdit}
onDuplicate={onDuplicate}
onChangeColor={onChangeColor}
onShowHistory={onShowHistory}
onToggleLock={onToggleLock}
onAssign={onAssign}
onSetDeadline={onSetDeadline}
onSetRequester={onSetRequester}
onArchive={onArchive}
onOpenCustomColor={onOpenCustomColor}
onRemoveSticker={onRemoveSticker}
onMoveSticker={onMoveSticker}
onCommitSticker={onCommitSticker}
/>
</Paper>
);
}
+179 -2
View File
@@ -12,6 +12,7 @@ import {
Paper,
Popover,
ScrollArea,
Select,
Stack,
Text,
TextInput,
@@ -25,6 +26,8 @@ import {
IconCheckbox,
IconChevronDown,
IconChevronRight,
IconClock,
IconDice5,
IconDotsVertical,
IconGripVertical,
IconPencil,
@@ -32,10 +35,30 @@ import {
IconTrash,
IconX,
} from "@tabler/icons-react";
import { memo, MouseEvent, useEffect, useRef, useState } from "react";
import { memo, MouseEvent, useEffect, useMemo, useRef, useState } from "react";
import type { Card, CardColor, Column, User } from "../types";
import { KanbanCard } from "./KanbanCard";
type MaxTimeUnit = "minutes" | "hours" | "days" | "weeks" | "months";
const MAX_TIME_UNIT_MIN: Record<MaxTimeUnit, number> = {
minutes: 1,
hours: 60,
days: 60 * 24,
weeks: 60 * 24 * 7,
months: 60 * 24 * 30,
};
const MAX_TIME_UNIT_LABEL: Record<MaxTimeUnit, string> = {
minutes: "minutos",
hours: "horas",
days: "dias",
weeks: "semanas",
months: "meses",
};
const MAX_TIME_UNIT_SELECT_DATA = (Object.keys(MAX_TIME_UNIT_LABEL) as MaxTimeUnit[]).map((u) => ({
value: u,
label: MAX_TIME_UNIT_LABEL[u],
}));
interface Props {
column: Column;
cards: Card[];
@@ -47,15 +70,19 @@ interface Props {
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
onDeleteColumn: (id: string) => void;
onSetWIPLimit: (id: string, limit: number) => void;
onSetMaxTimeMinutes: (id: string, minutes: number) => void;
onPickRandom: (columnId: string) => void;
onToggleDone: (id: string, is_done: boolean) => void;
onEditCard: (card: Card) => void;
onDeleteCard: (id: string) => void;
onDuplicateCard: (id: string) => void;
onChangeCardColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
onToggleCardLock: (id: string, locked: boolean) => void;
onAssignCard: (id: string, assignee_id: string | null) => void;
onSetCardDeadline?: (id: string, deadline: string | null) => void;
onSetRequester?: (id: string, requester: string) => void;
onArchiveCard?: (id: string) => void;
requesterOptions?: string[];
onOpenCustomCardColor?: (cardId: string, current: string) => void;
activeSticker?: string | null;
@@ -79,15 +106,19 @@ function KanbanColumnImpl({
onMoveColumnLocation,
onDeleteColumn,
onSetWIPLimit,
onSetMaxTimeMinutes,
onPickRandom,
onToggleDone,
onEditCard,
onDeleteCard,
onDuplicateCard,
onChangeCardColor,
onShowHistory,
onToggleCardLock,
onAssignCard,
onSetCardDeadline,
onSetRequester,
onArchiveCard,
requesterOptions,
onOpenCustomCardColor,
activeSticker,
@@ -104,6 +135,24 @@ function KanbanColumnImpl({
const [localWidth, setLocalWidth] = useState<number | null>(null);
const [wipPopOpen, setWipPopOpen] = useState(false);
const [wipDraft, setWipDraft] = useState<number | string>(column.wip_limit);
const [maxTimePopOpen, setMaxTimePopOpen] = useState(false);
// Initial unit picked from current value: largest unit that yields >=1
const pickInitialUnit = (mins: number): MaxTimeUnit => {
if (mins <= 0) return "minutes";
if (mins % 43200 === 0) return "months";
if (mins % 10080 === 0) return "weeks";
if (mins % 1440 === 0) return "days";
if (mins % 60 === 0) return "hours";
return "minutes";
};
const minutesToUnit = (mins: number, u: MaxTimeUnit): number => {
const div = MAX_TIME_UNIT_MIN[u];
return mins > 0 ? Math.max(1, Math.round(mins / div)) : 0;
};
const [maxTimeUnit, setMaxTimeUnit] = useState<MaxTimeUnit>(() => pickInitialUnit(column.max_time_minutes || 0));
const [maxTimeDraft, setMaxTimeDraft] = useState<number | string>(() =>
minutesToUnit(column.max_time_minutes || 0, pickInitialUnit(column.max_time_minutes || 0))
);
const [bodyHidden, setBodyHidden] = useState(() => {
if (!collapsed) return false;
return localStorage.getItem(`kanban_col_body_${column.id}`) === "1";
@@ -122,9 +171,13 @@ function KanbanColumnImpl({
setLocalWidth(null);
}, [column.width]);
const sortableData = useMemo(
() => ({ type: "column" as const, columnId: column.id, location: column.location }),
[column.id, column.location]
);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `column-${column.id}`,
data: { type: "column", columnId: column.id, location: column.location },
data: sortableData,
});
const effectiveWidth = collapsed ? "100%" : localWidth ?? column.width;
@@ -218,6 +271,8 @@ function KanbanColumnImpl({
withBorder
radius="md"
p="sm"
data-column-id={column.id}
data-column-location={column.location}
>
<Group justify="space-between" mb="xs" wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
@@ -388,6 +443,120 @@ function KanbanColumnImpl({
>
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
</Menu.Item>
<Popover
opened={maxTimePopOpen}
onChange={(o) => {
setMaxTimePopOpen(o);
if (o) {
const u = pickInitialUnit(column.max_time_minutes || 0);
setMaxTimeUnit(u);
setMaxTimeDraft(minutesToUnit(column.max_time_minutes || 0, u));
}
}}
position="right-start"
withArrow
shadow="md"
withinPortal={false}
>
<Popover.Target>
<Menu.Item
leftSection={<IconClock size={14} />}
data-test="column-max-time"
closeMenuOnClick={false}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMaxTimePopOpen((v) => !v);
}}
>
Tiempo maximo
{column.max_time_minutes > 0
? ` (${(() => {
const u = pickInitialUnit(column.max_time_minutes);
return `${minutesToUnit(column.max_time_minutes, u)} ${MAX_TIME_UNIT_LABEL[u]}`;
})()})`
: ""}
</Menu.Item>
</Popover.Target>
<Popover.Dropdown
p="xs"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
<Stack gap={6} style={{ minWidth: 240 }}>
<Text size="xs" c="dimmed">
Cards que pasen este tiempo se pintaran con borde rojo. 0 = sin limite. Columnas Done no aplican.
</Text>
<Group gap={6} wrap="nowrap">
<NumberInput
size="xs"
min={0}
max={999}
value={maxTimeDraft}
onChange={setMaxTimeDraft}
placeholder="0"
style={{ width: 90 }}
data-test="column-max-time-input"
/>
<Select
size="xs"
value={maxTimeUnit}
onChange={(v) => v && setMaxTimeUnit(v as MaxTimeUnit)}
data={MAX_TIME_UNIT_SELECT_DATA}
style={{ width: 130 }}
allowDeselect={false}
data-test="column-max-time-unit"
/>
</Group>
<Group justify="space-between" gap={6}>
<Tooltip label="Quitar limite" withArrow disabled={!column.max_time_minutes}>
<ActionIcon
size="sm"
variant="subtle"
color="red"
disabled={!column.max_time_minutes}
onClick={() => {
onSetMaxTimeMinutes(column.id, 0);
setMaxTimeDraft(0);
setMaxTimePopOpen(false);
}}
>
<IconTrash size={12} />
</ActionIcon>
</Tooltip>
<Button
size="xs"
data-test="column-max-time-save"
onClick={() => {
const raw =
typeof maxTimeDraft === "number"
? maxTimeDraft
: parseInt(String(maxTimeDraft), 10);
const n = Number.isFinite(raw) && raw >= 0 ? raw : 0;
const mins = n * MAX_TIME_UNIT_MIN[maxTimeUnit];
if (mins !== column.max_time_minutes) {
onSetMaxTimeMinutes(column.id, mins);
}
setMaxTimePopOpen(false);
}}
>
Guardar
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
{!column.is_done && (
<Menu.Item
leftSection={<IconDice5 size={14} />}
data-test="column-random-pick"
disabled={cards.filter((c) => !c.locked).length === 0}
onClick={() => onPickRandom(column.id)}
>
Seleccionar Aleatorio
</Menu.Item>
)}
<Menu.Item
leftSection={<ArchiveIcon size={14} />}
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
@@ -421,17 +590,24 @@ function KanbanColumnImpl({
now={now}
onDelete={onDeleteCard}
onEdit={onEditCard}
onDuplicate={onDuplicateCard}
onChangeColor={onChangeCardColor}
onShowHistory={onShowHistory}
onToggleLock={onToggleCardLock}
onAssign={onAssignCard}
onSetDeadline={onSetCardDeadline}
onSetRequester={onSetRequester}
onArchive={onArchiveCard}
requesterOptions={requesterOptions}
onOpenCustomColor={onOpenCustomCardColor}
users={users}
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
inDoneColumn={column.is_done}
columnOverdue={
!column.is_done &&
column.max_time_minutes > 0 &&
c.time_in_column_ms > column.max_time_minutes * 60_000
}
highlight={highlightCardId === c.id}
activeSticker={activeSticker}
onAddSticker={onAddSticker}
@@ -452,6 +628,7 @@ function KanbanColumnImpl({
onClick={() => onAddCard(column.id)}
mt="xs"
fullWidth
data-test="add-card"
>
Anadir tarjeta
</Button>
+42 -15
View File
@@ -10,8 +10,9 @@ import {
Title,
} from "@mantine/core";
import { IconLayoutKanban } from "@tabler/icons-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useAuth } from "../auth";
import * as api from "../api";
type Mode = "login" | "register";
@@ -23,6 +24,23 @@ export function LoginPage() {
const [displayName, setDisplayName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [registrationEnabled, setRegistrationEnabled] = useState(false);
const [appVersion, setAppVersion] = useState<string>("");
useEffect(() => {
api
.getFlags()
.then((f) => setRegistrationEnabled(!!f["registration-enabled"]))
.catch(() => setRegistrationEnabled(false));
api
.getVersion()
.then((v) => setAppVersion(v.version))
.catch(() => setAppVersion(""));
}, []);
useEffect(() => {
if (!registrationEnabled && mode === "register") setMode("login");
}, [registrationEnabled, mode]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -49,6 +67,9 @@ export function LoginPage() {
<Stack gap={4} align="center">
<IconLayoutKanban size={36} />
<Title order={3}>Kanban</Title>
{appVersion && (
<Text size="xs" c="dimmed" ff="monospace">v{appVersion}</Text>
)}
<Text size="sm" c="dimmed">
{mode === "login" ? "Inicia sesion" : "Crea una cuenta"}
</Text>
@@ -84,20 +105,26 @@ export function LoginPage() {
<Button type="submit" loading={submitting} fullWidth>
{mode === "login" ? "Entrar" : "Registrar"}
</Button>
<Text size="xs" c="dimmed" ta="center">
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
<Anchor
component="button"
type="button"
size="xs"
onClick={() => {
setError(null);
setMode(mode === "login" ? "register" : "login");
}}
>
{mode === "login" ? "Registrate" : "Inicia sesion"}
</Anchor>
</Text>
{registrationEnabled ? (
<Text size="xs" c="dimmed" ta="center">
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
<Anchor
component="button"
type="button"
size="xs"
onClick={() => {
setError(null);
setMode(mode === "login" ? "register" : "login");
}}
>
{mode === "login" ? "Registrate" : "Inicia sesion"}
</Anchor>
</Text>
) : (
<Text size="xs" c="dimmed" ta="center">
Registro de nuevos usuarios deshabilitado.
</Text>
)}
</Stack>
</form>
</Paper>
+192
View File
@@ -0,0 +1,192 @@
import {
ActionIcon,
Alert,
Box,
Button,
Code,
CopyButton,
Divider,
Group,
Loader,
Modal,
Stack,
Table,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconCopy, IconCheck, IconTrash } from "@tabler/icons-react";
import { useCallback, useEffect, useState } from "react";
import * as api from "../api";
import type { MCPToken, MCPTokenCreated } from "../api";
import { formatDateTimeShort } from "./format";
interface Props {
opened: boolean;
onClose: () => void;
}
export function MCPTokensModal({ opened, onClose }: Props) {
const [tokens, setTokens] = useState<MCPToken[]>([]);
const [loading, setLoading] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [justCreated, setJustCreated] = useState<MCPTokenCreated | null>(null);
const reload = useCallback(async () => {
setLoading(true);
try {
setTokens(await api.listMCPTokens());
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (opened) {
reload();
setJustCreated(null);
setNewName("");
}
}, [opened, reload]);
const create = async () => {
const name = newName.trim() || "default";
setCreating(true);
try {
const t = await api.createMCPToken(name);
setJustCreated(t);
setNewName("");
await reload();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setCreating(false);
}
};
const revoke = async (id: string) => {
if (!confirm("Revocar este token? Quien lo este usando dejara de tener acceso.")) return;
try {
await api.revokeMCPToken(id);
await reload();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
};
const mcpURL = `${window.location.origin}/mcp`;
const claudeCmd = justCreated
? `claude mcp add kanban --transport http ${mcpURL} --header "Authorization: Bearer ${justCreated.token}"`
: "";
return (
<Modal opened={opened} onClose={onClose} title="MCP Tokens" size="lg">
<Stack gap="md">
<Text size="sm" c="dimmed">
Cada token deja conectar un cliente Claude al kanban como tu usuario.
El valor solo aparece UNA vez al crearlo. Si lo pierdes, generas otro y revocas el antiguo.
</Text>
<Group align="end">
<TextInput
label="Nombre del token"
placeholder="ej. portatil, sobremesa..."
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
style={{ flex: 1 }}
disabled={creating}
/>
<Button onClick={create} loading={creating}>
Generar
</Button>
</Group>
{justCreated && (
<Alert color="yellow" title="Copia el token ahora — no se mostrara mas">
<Stack gap="xs">
<Group gap="xs" align="center">
<Code style={{ flex: 1, wordBreak: "break-all" }}>{justCreated.token}</Code>
<CopyButton value={justCreated.token}>
{({ copied, copy }) => (
<Tooltip label={copied ? "Copiado" : "Copiar token"}>
<ActionIcon variant="subtle" onClick={copy}>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
<Divider />
<Text size="xs" c="dimmed">
Pega este comando en tu PC para registrar el MCP en Claude Code:
</Text>
<Group gap="xs" align="center">
<Code block style={{ flex: 1 }}>{claudeCmd}</Code>
<CopyButton value={claudeCmd}>
{({ copied, copy }) => (
<Tooltip label={copied ? "Copiado" : "Copiar comando"}>
<ActionIcon variant="subtle" onClick={copy}>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
</Stack>
</Alert>
)}
<Divider label="Tokens activos" labelPosition="left" />
{loading ? (
<Group justify="center" p="md">
<Loader size="sm" />
</Group>
) : tokens.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
Sin tokens. Genera uno arriba.
</Text>
) : (
<Table withTableBorder withColumnBorders verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Nombre</Table.Th>
<Table.Th>Creado</Table.Th>
<Table.Th>Ultimo uso</Table.Th>
<Table.Th w={60} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{tokens.map((t) => (
<Table.Tr key={t.id}>
<Table.Td>{t.name}</Table.Td>
<Table.Td>{formatDateTimeShort(t.created_at)}</Table.Td>
<Table.Td>
{t.last_used_at ? formatDateTimeShort(t.last_used_at) : <Text c="dimmed">nunca</Text>}
</Table.Td>
<Table.Td>
<Tooltip label="Revocar">
<ActionIcon color="red" variant="subtle" onClick={() => revoke(t.id)}>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Box>
<Text size="xs" c="dimmed">
Endpoint MCP: <Code>{mcpURL}</Code>
</Text>
</Box>
</Stack>
</Modal>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { Anchor, Image, Text } from "@mantine/core";
import { Fragment, ReactNode } from "react";
// Minimal markdown renderer for chat/desc embeds (issue 0128).
// Recognises:
// ![alt](url) -> <Image>
// [name](url) -> <Anchor>name</Anchor>
// Everything else is plain text with preserved whitespace.
interface ImgToken { kind: "img"; alt: string; url: string }
interface LinkToken { kind: "link"; label: string; url: string }
interface TextToken { kind: "text"; value: string }
type Token = ImgToken | LinkToken | TextToken;
const TOKEN_RE = /(!\[([^\]\n]*)\]\(([^)\s]+)\))|(\[([^\]\n]+)\]\(([^)\s]+)\))/g;
// Allow only safe URL schemes. Reject javascript:, data:text/html, vbscript:, etc.
// Accepts: absolute http(s), protocol-relative //, and same-origin paths (/...).
function safeURL(url: string): string | null {
const u = url.trim();
if (u.startsWith("/")) return u;
if (/^https?:\/\//i.test(u)) return u;
return null;
}
// data: scheme is allowed only when the MIME prefix is image/.
function safeImageURL(url: string): string | null {
const safe = safeURL(url);
if (safe) return safe;
const u = url.trim();
if (/^data:image\/[a-z0-9.+-]+(;[a-z0-9-]+=[^,]+)*;base64,/i.test(u)) return u;
return null;
}
function tokenize(input: string): Token[] {
const out: Token[] = [];
let last = 0;
let m: RegExpExecArray | null;
TOKEN_RE.lastIndex = 0;
while ((m = TOKEN_RE.exec(input)) !== null) {
if (m.index > last) {
out.push({ kind: "text", value: input.slice(last, m.index) });
}
if (m[1]) {
const url = safeImageURL(m[3]);
if (url) {
out.push({ kind: "img", alt: m[2] || "", url });
} else {
out.push({ kind: "text", value: m[0] });
}
} else if (m[4]) {
const url = safeURL(m[6]);
if (url) {
out.push({ kind: "link", label: m[5], url });
} else {
out.push({ kind: "text", value: m[0] });
}
}
last = TOKEN_RE.lastIndex;
}
if (last < input.length) {
out.push({ kind: "text", value: input.slice(last) });
}
return out;
}
interface Props {
text: string;
size?: string;
}
export function MessageBody({ text, size = "sm" }: Props): ReactNode {
const tokens = tokenize(text);
if (tokens.length === 0) {
return null;
}
const inlineNodes: ReactNode[] = [];
const blocks: ReactNode[] = [];
let key = 0;
const flushInline = () => {
if (inlineNodes.length === 0) return;
blocks.push(
<Text
key={`t-${key++}`}
size={size}
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
{inlineNodes.map((n, i) => (
<Fragment key={i}>{n}</Fragment>
))}
</Text>
);
inlineNodes.length = 0;
};
for (const t of tokens) {
if (t.kind === "img") {
flushInline();
blocks.push(
<Anchor key={`i-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
<Image
src={t.url}
alt={t.alt}
maw={220}
mah={220}
fit="contain"
radius="sm"
/>
</Anchor>
);
} else if (t.kind === "link") {
inlineNodes.push(
<Anchor key={`l-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
{t.label}
</Anchor>
);
} else {
inlineNodes.push(t.value);
}
}
flushInline();
return <>{blocks}</>;
}
+441
View File
@@ -0,0 +1,441 @@
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Checkbox,
Code,
Divider,
Group,
JsonInput,
Loader,
Modal,
ScrollArea,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconPlug, IconPlugConnected, IconRefresh, IconTestPipe, IconTrash } from "@tabler/icons-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import * as api from "../api";
import type { KanbanModule, ModuleLog } from "../types";
import { formatDateTimeShort } from "./format";
interface Props {
opened: boolean;
onClose: () => void;
}
const KANBAN_EVENTS = [
"card.created",
"card.updated",
"card.moved",
"card.deleted",
"message.created",
"board.invalidated",
];
const DEFAULT_JIRA_CONFIG = {
base_url: "",
email: "",
api_token: "",
project_key: "",
status_map: {
"Por hacer": "To Do",
"Doing": "In Progress",
"Done": "Done",
},
};
export function ModulesModal({ opened, onClose }: Props) {
const [modules, setModules] = useState<KanbanModule[]>([]);
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [editing, setEditing] = useState<KanbanModule | null>(null);
const [logs, setLogs] = useState<ModuleLog[]>([]);
const [logsLoading, setLogsLoading] = useState(false);
const [activeTab, setActiveTab] = useState<string | null>("form");
const reload = useCallback(async () => {
setLoading(true);
try {
const list = await api.listModules();
setModules(list);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (opened) reload();
}, [opened, reload]);
const reloadLogs = useCallback(async (id: string) => {
setLogsLoading(true);
try {
const out = await api.listModuleLogs(id);
setLogs(out);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setLogsLoading(false);
}
}, []);
const select = (m: KanbanModule | null) => {
setEditing(m ? { ...m, config: { ...m.config } } : null);
setSelectedId(m?.id ?? null);
setActiveTab("form");
setLogs([]);
if (m) reloadLogs(m.id);
};
const startNew = () => {
const blank: KanbanModule = {
id: "",
name: "Nuevo modulo",
kind: "jira",
enabled: false,
event_filter: ["card.created", "card.updated", "card.moved", "message.created"],
config: { ...DEFAULT_JIRA_CONFIG, status_map: { ...DEFAULT_JIRA_CONFIG.status_map } },
created_at: "",
updated_at: "",
};
setEditing(blank);
setSelectedId(null);
setActiveTab("form");
setLogs([]);
};
const save = async () => {
if (!editing) return;
try {
const payload = {
name: editing.name,
kind: editing.kind,
enabled: editing.enabled,
event_filter: editing.event_filter,
config: editing.config,
};
const saved = editing.id
? await api.updateModule(editing.id, payload)
: await api.createModule(payload);
notifications.show({ color: "green", message: "Modulo guardado" });
await reload();
select(saved);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
};
const remove = async () => {
if (!selectedId) return;
if (!confirm("Borrar modulo?")) return;
try {
await api.deleteModule(selectedId);
notifications.show({ color: "green", message: "Modulo borrado" });
setEditing(null);
setSelectedId(null);
reload();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
};
const test = async () => {
if (!editing) return;
try {
const result = editing.id
? await api.testModule(editing.id)
: await api.testModule("draft", {
name: editing.name,
kind: editing.kind,
enabled: editing.enabled,
event_filter: editing.event_filter,
config: editing.config,
});
if (result.ok) {
notifications.show({
color: "green",
title: `Test OK (${result.status})`,
message: `Conexion verificada en ${result.duration_ms}ms`,
});
} else {
notifications.show({
color: "red",
title: `Test fallo (${result.status})`,
message: result.error || "sin detalle",
});
}
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap={8}>
<IconPlug size={18} />
<Text fw={600}>Modulos / Integraciones</Text>
</Group>
}
size="xl"
centered
>
<Group align="flex-start" gap="md" wrap="nowrap">
<Box style={{ width: 220, minWidth: 220 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed">Configurados</Text>
<Tooltip label="Refrescar" withArrow>
<ActionIcon size="sm" variant="subtle" onClick={reload}>
<IconRefresh size={14} />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea h={400} type="auto">
<Stack gap={4}>
{loading && <Loader size="xs" />}
{modules.map((m) => (
<Box
key={m.id}
p="xs"
style={{
cursor: "pointer",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 4,
background:
selectedId === m.id ? "var(--mantine-color-blue-light)" : undefined,
}}
onClick={() => select(m)}
>
<Group justify="space-between" gap={4} wrap="nowrap">
<Text size="sm" fw={600} truncate>
{m.name}
</Text>
<Badge size="xs" color={m.enabled ? "green" : "gray"}>
{m.enabled ? "on" : "off"}
</Badge>
</Group>
<Text size="xs" c="dimmed">{m.kind}</Text>
</Box>
))}
<Button size="xs" variant="light" onClick={startNew} mt="xs">
+ Nuevo
</Button>
</Stack>
</ScrollArea>
</Box>
<Divider orientation="vertical" />
<Box style={{ flex: 1, minWidth: 0 }}>
{!editing ? (
<Alert color="gray">Selecciona un modulo o pulsa "Nuevo".</Alert>
) : (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="form">Configuracion</Tabs.Tab>
<Tabs.Tab value="logs">Logs</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="form" pt="xs">
<Stack gap="xs">
<Group gap="xs">
<TextInput
label="Nombre"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.currentTarget.value })}
style={{ flex: 1 }}
/>
<Select
label="Kind"
value={editing.kind}
onChange={(v) => setEditing({ ...editing, kind: v || "jira" })}
data={[{ value: "jira", label: "Jira" }]}
w={140}
/>
</Group>
<Checkbox
label="Activo"
checked={editing.enabled}
onChange={(e) => setEditing({ ...editing, enabled: e.currentTarget.checked })}
/>
<Box>
<Text size="xs" fw={600} mb={4}>Eventos</Text>
<Group gap="xs">
{KANBAN_EVENTS.map((ev) => (
<Checkbox
key={ev}
label={<Code>{ev}</Code>}
checked={editing.event_filter.includes(ev)}
onChange={(e) => {
const next = e.currentTarget.checked
? [...editing.event_filter, ev]
: editing.event_filter.filter((x) => x !== ev);
setEditing({ ...editing, event_filter: next });
}}
/>
))}
</Group>
</Box>
<JiraConfigEditor editing={editing} setEditing={setEditing} />
<Group gap="xs">
<Button onClick={save} leftSection={<IconPlugConnected size={14} />}>
Guardar
</Button>
<Button variant="default" onClick={test} leftSection={<IconTestPipe size={14} />}>
Probar conexion
</Button>
{selectedId && (
<Button
color="red"
variant="subtle"
onClick={remove}
leftSection={<IconTrash size={14} />}
ml="auto"
>
Borrar
</Button>
)}
</Group>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="logs" pt="xs">
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed">Ultimas 100 entradas</Text>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => selectedId && reloadLogs(selectedId)}
>
<IconRefresh size={14} />
</ActionIcon>
</Group>
{logsLoading ? (
<Loader size="sm" />
) : logs.length === 0 ? (
<Text size="sm" c="dimmed">Sin entradas.</Text>
) : (
<ScrollArea h={400}>
<Table withTableBorder striped highlightOnHover stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>Hora</Table.Th>
<Table.Th>Evento</Table.Th>
<Table.Th>HTTP</Table.Th>
<Table.Th>ms</Table.Th>
<Table.Th>Error</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{logs.map((l) => (
<Table.Tr key={l.id}>
<Table.Td>{formatDateTimeShort(l.created_at)}</Table.Td>
<Table.Td><Code>{l.event_type}</Code></Table.Td>
<Table.Td>
<Badge color={l.status >= 400 || l.error ? "red" : "green"} size="sm">
{l.status || "-"}
</Badge>
</Table.Td>
<Table.Td>{l.duration_ms}</Table.Td>
<Table.Td>
<Text size="xs" c="red" lineClamp={2}>{l.error}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
)}
</Tabs.Panel>
</Tabs>
)}
</Box>
</Group>
</Modal>
);
}
interface JiraConfigEditorProps {
editing: KanbanModule;
setEditing: (m: KanbanModule) => void;
}
function JiraConfigEditor({ editing, setEditing }: JiraConfigEditorProps) {
const cfg = editing.config as Record<string, unknown>;
const set = (key: string, value: unknown) =>
setEditing({ ...editing, config: { ...cfg, [key]: value } });
const statusMapText = useMemo(() => {
return JSON.stringify(cfg.status_map ?? {}, null, 2);
}, [cfg.status_map]);
if (editing.kind !== "jira") {
return (
<Alert color="yellow" mt="xs">
Editor especifico para esta kind aun no implementado.
</Alert>
);
}
return (
<Stack gap="xs">
<TextInput
label="Base URL"
placeholder="https://acme.atlassian.net"
value={(cfg.base_url as string) || ""}
onChange={(e) => set("base_url", e.currentTarget.value)}
/>
<Group gap="xs">
<TextInput
label="Email"
value={(cfg.email as string) || ""}
onChange={(e) => set("email", e.currentTarget.value)}
style={{ flex: 1 }}
/>
<TextInput
label="API token"
placeholder={editing.id ? "*** (deja vacio para conservar)" : ""}
value={(cfg.api_token as string) || ""}
onChange={(e) => set("api_token", e.currentTarget.value)}
style={{ flex: 1 }}
/>
</Group>
<TextInput
label="Project key"
placeholder="KAN"
value={(cfg.project_key as string) || ""}
onChange={(e) => set("project_key", e.currentTarget.value)}
/>
<JsonInput
label="Status map (columna kanban → transicion Jira)"
description='{"Doing":"In Progress","Done":"Done"}'
value={statusMapText}
autosize
minRows={3}
validationError="JSON invalido"
onChange={(v) => {
try {
const parsed = JSON.parse(v);
set("status_map", parsed);
} catch {
// Hold invalid input in textarea via raw state; final save will
// reuse last valid parse.
}
}}
/>
</Stack>
);
}
@@ -0,0 +1,199 @@
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Indicator,
Loader,
Popover,
ScrollArea,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconAt, IconBell, IconCheck, IconMessage, IconUserCheck } from "@tabler/icons-react";
import { ReactElement, useCallback, useEffect, useState } from "react";
import * as api from "../api";
import type { Notification, NotificationKind } from "../types";
import { formatDateTimeShort } from "./format";
interface Props {
// External counter — App.tsx updates this via SSE events. When undefined
// the bell polls /api/notifications/unread-count on mount.
unreadCount?: number;
notifications?: Notification[];
// Called when the user clicks a notification → open the relevant card.
// messageId points to the chat message that triggered the notification so
// the parent can scroll to it.
onOpenCard?: (cardId: string, messageId: string) => void;
// Called whenever the bell mutates state (mark read / mark all) so the
// parent can refresh its cached lists.
onChanged?: () => void;
}
const kindIcon: Record<NotificationKind, ReactElement> = {
mention: <IconAt size={14} />,
assigned_chat: <IconUserCheck size={14} />,
reply: <IconMessage size={14} />,
};
const kindLabel: Record<NotificationKind, string> = {
mention: "Mencion",
assigned_chat: "Asignado",
reply: "Respuesta",
};
const kindColor: Record<NotificationKind, string> = {
mention: "grape",
assigned_chat: "blue",
reply: "gray",
};
export function NotificationsBell({ unreadCount: extCount, notifications: extList, onOpenCard, onChanged }: Props) {
const [opened, setOpened] = useState(false);
const [items, setItems] = useState<Notification[]>(extList ?? []);
const [count, setCount] = useState<number>(extCount ?? 0);
const [loading, setLoading] = useState(false);
// Keep local state in sync with parent-supplied values when present.
useEffect(() => {
if (extList) setItems(extList);
}, [extList]);
useEffect(() => {
if (extCount !== undefined) setCount(extCount);
}, [extCount]);
const refresh = useCallback(async () => {
setLoading(true);
try {
const [list, c] = await Promise.all([
api.listNotifications(false),
api.unreadNotificationCount(),
]);
setItems(list);
setCount(c.count);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// Initial fetch only when parent does not provide list/count.
if (extList === undefined || extCount === undefined) {
refresh();
}
}, [extList, extCount, refresh]);
const handleOpen = (isOpen: boolean) => {
setOpened(isOpen);
if (isOpen) refresh();
};
const handleClick = async (n: Notification) => {
if (!n.read_at) {
try {
await api.markNotificationRead(n.id);
setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, read_at: new Date().toISOString() } : x)));
setCount((c) => Math.max(0, c - 1));
onChanged?.();
} catch {
// ignore — UI will recover on next refresh
}
}
setOpened(false);
onOpenCard?.(n.card_id, n.message_id);
};
const handleMarkAll = async () => {
try {
await api.markAllNotificationsRead();
setItems((prev) => prev.map((x) => (x.read_at ? x : { ...x, read_at: new Date().toISOString() })));
setCount(0);
onChanged?.();
} catch {
// ignore
}
};
const badge = (
<ActionIcon variant="subtle" aria-label="Notificaciones">
<IconBell size={16} />
</ActionIcon>
);
return (
<Popover opened={opened} onChange={handleOpen} position="bottom-end" width={380} withArrow shadow="md">
<Popover.Target>
<Box onClick={() => handleOpen(!opened)} style={{ display: "inline-flex" }}>
{count > 0 ? (
<Indicator color="red" label={count > 99 ? "99+" : count} size={16} offset={4}>
{badge}
</Indicator>
) : (
badge
)}
</Box>
</Popover.Target>
<Popover.Dropdown p={0}>
<Group justify="space-between" px="sm" py="xs">
<Text fw={600} size="sm">Notificaciones</Text>
<Tooltip label="Marcar todas como leidas" withArrow>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCheck size={12} />}
onClick={handleMarkAll}
disabled={count === 0}
>
Todas leidas
</Button>
</Tooltip>
</Group>
<ScrollArea h={420} type="auto" offsetScrollbars>
{loading && items.length === 0 ? (
<Group justify="center" p="md"><Loader size="sm" /></Group>
) : items.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" p="md">Sin notificaciones</Text>
) : (
<Stack gap={0}>
{items.map((n) => {
const unread = !n.read_at;
return (
<UnstyledButton
key={n.id}
onClick={() => handleClick(n)}
p="sm"
style={{
borderTop: "1px solid var(--mantine-color-gray-2)",
background: unread ? "var(--mantine-color-blue-light)" : undefined,
textAlign: "left",
}}
>
<Group gap={6} wrap="nowrap" align="flex-start">
<Badge size="xs" variant="light" color={kindColor[n.kind]} leftSection={kindIcon[n.kind]}>
{kindLabel[n.kind]}
</Badge>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap" justify="space-between">
<Text size="xs" fw={600} truncate>
{n.actor_name || "Alguien"} · #{n.card_seq_num} {n.card_title}
</Text>
<Text size="xs" c="dimmed">{formatDateTimeShort(n.created_at)}</Text>
</Group>
<Text size="xs" c={unread ? undefined : "dimmed"} lineClamp={2} style={{ whiteSpace: "pre-wrap" }}>
{n.snippet}
</Text>
</Box>
</Group>
</UnstyledButton>
);
})}
</Stack>
)}
</ScrollArea>
</Popover.Dropdown>
</Popover>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useRef } from "react";
export type EventStreamHandlers = Record<string, (payload: unknown) => void>;
// useEventStream connects to /api/events via EventSource and dispatches
// named events to the matching handler. The handlers object is captured in
// a ref so callers can supply fresh closures every render without tearing
// the connection down. Reconnection is handled by the browser's built-in
// EventSource backoff; the hook only opens one socket per mount.
export function useEventStream(handlers: EventStreamHandlers, enabled = true) {
const ref = useRef(handlers);
ref.current = handlers;
useEffect(() => {
if (!enabled) return;
const es = new EventSource("/api/events", { withCredentials: true });
const listeners: Record<string, (ev: MessageEvent) => void> = {};
// We attach a listener per event type known when this effect runs.
// Types added later via handler ref updates are still handled because
// the inner closure always reads ref.current.
for (const type of Object.keys(ref.current)) {
const fn = (ev: MessageEvent) => {
const cb = ref.current[type];
if (!cb) return;
try {
const payload = ev.data ? JSON.parse(ev.data) : null;
cb(payload);
} catch {
// Malformed payload; ignore.
}
};
es.addEventListener(type, fn);
listeners[type] = fn;
}
return () => {
for (const [type, fn] of Object.entries(listeners)) {
es.removeEventListener(type, fn);
}
es.close();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]);
}
+2
View File
@@ -1,5 +1,7 @@
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import "./styles/roulette.css";
import "./styles/dropzone.css";
import { MantineProvider, createTheme } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
+52
View File
@@ -0,0 +1,52 @@
/* Drag-aware dropzone strip on the left edge.
* Issue 0091 auto-open sidebar when dragging a card near the left edge.
*
* The strip is only visible while a drag is active. When the pointer is
* inside the strip, we add the `is-armed` class to show a subtle inset
* glow that pulses, so the user knows the zone is going to fire.
*/
.kanban-drag-edge {
position: fixed;
left: 0;
top: 50px; /* AppShell.Header height */
bottom: 0;
width: 32px;
z-index: 200;
pointer-events: none; /* let dnd-kit keep capturing the pointer */
opacity: 0;
transition: opacity 120ms ease-out, box-shadow 160ms ease-out, background 160ms ease-out;
background: transparent;
}
.kanban-drag-edge.is-active {
opacity: 1;
/* Very subtle hint that the strip exists during any drag. */
box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.04);
}
.kanban-drag-edge.is-armed {
background: linear-gradient(
90deg,
rgba(34, 139, 230, 0.18) 0%,
rgba(34, 139, 230, 0.06) 60%,
transparent 100%
);
box-shadow: inset 4px 0 0 var(--mantine-color-blue-4);
animation: kanban-drag-edge-pulse 1100ms ease-in-out infinite;
}
@keyframes kanban-drag-edge-pulse {
0% {
box-shadow: inset 4px 0 0 var(--mantine-color-blue-4),
inset 0 0 0 0 rgba(34, 139, 230, 0.0);
}
50% {
box-shadow: inset 4px 0 0 var(--mantine-color-blue-5),
inset 16px 0 22px -10px rgba(34, 139, 230, 0.35);
}
100% {
box-shadow: inset 4px 0 0 var(--mantine-color-blue-4),
inset 0 0 0 0 rgba(34, 139, 230, 0.0);
}
}
+30
View File
@@ -0,0 +1,30 @@
/* Issue 0090: ruleta de seleccion aleatoria por columna. */
@keyframes kanban-roulette-pulse {
0% { box-shadow: 0 0 0 0 rgba(34, 139, 230, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(34, 139, 230, 0); }
100% { box-shadow: 0 0 0 0 rgba(34, 139, 230, 0); }
}
@keyframes kanban-roulette-winner {
0% { box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.95); transform: scale(1); }
30% { box-shadow: 0 0 0 16px rgba(82, 196, 26, 0.55); transform: scale(1.03); }
60% { box-shadow: 0 0 0 22px rgba(82, 196, 26, 0); transform: scale(1.05); }
100% { box-shadow: 0 0 0 0 rgba(82, 196, 26, 0); transform: scale(1); }
}
.kanban-roulette-active {
outline: 3px solid var(--mantine-color-blue-6) !important;
outline-offset: -2px;
animation: kanban-roulette-pulse 200ms ease-out 1;
z-index: 5;
position: relative;
}
.kanban-roulette-winner {
outline: 3px solid var(--mantine-color-green-7) !important;
outline-offset: -2px;
animation: kanban-roulette-winner 1600ms ease-out 1;
z-index: 6;
position: relative;
}
+58
View File
@@ -0,0 +1,58 @@
import "@testing-library/jest-dom/vitest";
// jsdom does not implement matchMedia; Mantine reads it on mount.
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
// Mantine Textarea autosize reads window.visualViewport on mount; jsdom lacks it.
if (typeof window !== "undefined" && !window.visualViewport) {
Object.defineProperty(window, "visualViewport", {
writable: true,
value: {
width: 1280,
height: 800,
offsetLeft: 0,
offsetTop: 0,
pageLeft: 0,
pageTop: 0,
scale: 1,
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
},
});
}
// jsdom does not implement document.fonts; Mantine Autosize reads it on mount.
if (typeof document !== "undefined" && !(document as Document & { fonts?: unknown }).fonts) {
Object.defineProperty(document, "fonts", {
writable: true,
value: {
ready: Promise.resolve(),
addEventListener: () => {},
removeEventListener: () => {},
},
});
}
// ResizeObserver is used by some Mantine components and is not in jsdom.
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
+71
View File
@@ -8,6 +8,7 @@ export interface Column {
width: number;
wip_limit: number;
is_done: boolean;
max_time_minutes: number;
created_at: string;
}
@@ -33,6 +34,7 @@ export interface Card {
assignee_id: string | null;
completed_at: string | null;
deleted_at: string | null;
archived_at: string | null;
tags: string[];
stickers: Sticker[];
deadline: string | null;
@@ -44,14 +46,58 @@ export interface Card {
total_locked_ms: number;
}
export interface CardFile {
id: string;
card_id: string;
uploader_id: string;
filename: string;
mime: string;
size: number;
source: "upload" | "description" | "chat";
url: string;
created_at: string;
}
export interface User {
id: string;
username: string;
display_name: string;
color: string;
is_admin?: boolean;
created_at: string;
}
export type ModuleKind = "jira" | "webhook";
export interface KanbanModule {
id: string;
name: string;
kind: ModuleKind | string;
enabled: boolean;
event_filter: string[];
config: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface ModuleLog {
id: string;
module_id: string;
event_type: string;
card_id: string;
status: number;
duration_ms: number;
error: string;
created_at: string;
}
export interface ModuleTestResult {
ok: boolean;
status: number;
duration_ms: number;
error?: string;
}
export interface MetricsRange {
from: string;
to: string;
@@ -188,3 +234,28 @@ export interface CardHistoryResponse {
total_locked_ms: number;
currently_locked: boolean;
}
export interface CardMessage {
id: string;
card_id: string;
author_id: string | null;
body: string;
created_at: string;
}
export type NotificationKind = "mention" | "assigned_chat" | "reply";
export interface Notification {
id: string;
user_id: string;
card_id: string;
message_id: string;
kind: NotificationKind;
actor_id: string;
created_at: string;
read_at: string | null;
card_title: string;
card_seq_num: number;
actor_name: string;
snippet: string;
}
+9 -1
View File
@@ -12,7 +12,15 @@ export default defineConfig({
server: {
port: 5180,
proxy: {
"/api": "http://localhost:8095",
"/api": {
target: process.env.VITE_API_TARGET || "http://127.0.0.1:8095",
ws: true,
changeOrigin: true,
},
"/mcp": {
target: process.env.VITE_API_TARGET || "http://127.0.0.1:8095",
changeOrigin: true,
},
},
},
build: {
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@fn_library": path.resolve(__dirname, "../../../frontend/functions"),
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
exclude: ["e2e/**", "node_modules/**"],
},
});
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More