Add Cloudflare email and collaboration workflows
This commit is contained in:
17
.env.example
17
.env.example
@@ -1,11 +1,24 @@
|
|||||||
# Required for deployed passkeys and device authorization URLs.
|
# Required for deployed passkeys and device authorization URLs.
|
||||||
CAIRNQUIRE_PUBLIC_ORIGIN=https://cairnquire.example.com
|
CAIRNQUIRE_PUBLIC_ORIGIN=https://cairnquire.example.com
|
||||||
|
|
||||||
# Optional SMTP settings. The bundled Compose file defaults to its local
|
# Outbound provider: smtp, postmark, cloudflare, or noop. Compose defaults to
|
||||||
# Mailpit service so notifications can be inspected during initial deployment.
|
# smtp with Mailpit so messages can be inspected during initial deployment.
|
||||||
|
CAIRNQUIRE_EMAIL_PROVIDER=smtp
|
||||||
CAIRNQUIRE_EMAIL_SMTP_HOST=mailpit
|
CAIRNQUIRE_EMAIL_SMTP_HOST=mailpit
|
||||||
CAIRNQUIRE_EMAIL_SMTP_PORT=1025
|
CAIRNQUIRE_EMAIL_SMTP_PORT=1025
|
||||||
CAIRNQUIRE_EMAIL_FROM=notifications@cairnquire.local
|
CAIRNQUIRE_EMAIL_FROM=notifications@cairnquire.local
|
||||||
|
|
||||||
|
# Postmark production delivery:
|
||||||
|
# CAIRNQUIRE_EMAIL_PROVIDER=postmark
|
||||||
|
# CAIRNQUIRE_EMAIL_POSTMARK_TOKEN=server-token
|
||||||
|
# CAIRNQUIRE_EMAIL_FROM=notifications@example.com
|
||||||
|
|
||||||
|
# Cloudflare Email Service production delivery. Enable Email Sending for the
|
||||||
|
# sender domain first and use an API token with Email Sending permission.
|
||||||
|
# CAIRNQUIRE_EMAIL_PROVIDER=cloudflare
|
||||||
|
# CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID=cloudflare-account-id
|
||||||
|
# CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN=cloudflare-api-token
|
||||||
|
# CAIRNQUIRE_EMAIL_FROM=notifications@example.com
|
||||||
|
|
||||||
# Optional: INFO, DEBUG, WARN, or ERROR.
|
# Optional: INFO, DEBUG, WARN, or ERROR.
|
||||||
CAIRNQUIRE_LOG_LEVEL=INFO
|
CAIRNQUIRE_LOG_LEVEL=INFO
|
||||||
|
|||||||
@@ -1,64 +1,74 @@
|
|||||||
# Project Status Report
|
# Project Status Report
|
||||||
|
|
||||||
**Generated:** 2026-06-24
|
**Updated:** 2026-07-22
|
||||||
**Branch:** main
|
**Branch:** main
|
||||||
**Last Commit:** b6d2ded - passkey existing (2026-06-16)
|
**Last Commit:** 09f51d1 - Add queued email notifications for collaboration events
|
||||||
|
|
||||||
## Quick Stats
|
## Quick Stats
|
||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| Total Tracked Files | 189 |
|
| Total Tracked Files | 201 |
|
||||||
| Go Files | 46 |
|
| Go Files | 63 |
|
||||||
| Swift Files | 14 (macOS app) |
|
| Swift Files | 15 (macOS app and package manifest) |
|
||||||
| Test Files | 10 |
|
| Go Test Files | 18 |
|
||||||
| Avg Test Coverage | ~39% (markdown 87%, store 70%, auth 55%, sync 54%) |
|
| Aggregate Go Test Coverage | 52.4% |
|
||||||
| Milestones Complete | 3/6 (50%) |
|
| Milestones Complete | 3/6 (50%) |
|
||||||
|
|
||||||
## Recent Activity (since 2026-06-01 report)
|
## Verification
|
||||||
|
|
||||||
16 commits on `main` between `fb0673a` (2026-06-01) and `b6d2ded` (2026-06-16):
|
- `go test -coverpkg=./... -coverprofile=... ./...` passes with 52.4% aggregate statement coverage.
|
||||||
71 files changed, +7908 / -812 lines.
|
- `swift build` passes for the macOS app and CLI.
|
||||||
|
- Focused race tests pass for email, collaboration, and HTTP packages. A full `go test -race ./...` exceeds the current five-minute local timeout.
|
||||||
|
- `govulncheck` currently fails because Go 1.24.2 has 31 reachable standard-library vulnerabilities; the pinned Go toolchain must be upgraded before production sign-off.
|
||||||
|
- Highest package coverage: Markdown 87.0%, email 77.9%, store 70.0%, config 67.9%, auth 54.9%, and sync 54.2%.
|
||||||
|
- Collaboration, configuration, email-provider, and collaboration HTTP integration tests are present; database, logging, and realtime still lack direct suites.
|
||||||
|
|
||||||
Highlights:
|
## Recent Activity
|
||||||
- `b6d2ded` passkey existing-user login flow
|
|
||||||
- `4655008` text editor link autocomplete
|
Recent work has moved the project beyond the previous 2026-06-24 report:
|
||||||
- `0adb980` CodeMirror editor integration
|
|
||||||
- `c39c50b` dropdown menu + comment side panel (wires M4 comment UI)
|
- Durable email queue with retry/backoff and failed-delivery inspection.
|
||||||
- `6c30fd8` sync service update
|
- Postmark outbound sender with API-level tests.
|
||||||
- `04a1f2b` ops design + app scaffolding (`entrypoint.sh`)
|
- Cloudflare Email Service REST sender with retry/permanent-error classification and API-level tests.
|
||||||
- `632621b`/`1e939f3` macOS menu-bar app: live search popover, drop-target icon
|
- File-change and comment notifications now enqueue email delivery.
|
||||||
- `ddc7d5c` accounts and email wiring
|
- Notification bell/list/read UI is active.
|
||||||
- `93e96be`/`73d505a` UI CSS expansion (~1900 lines) and workspace browser polish
|
- Document watch/unwatch controls are active, watcher subscriptions are deduplicated, and ancestor-folder matching is fixed.
|
||||||
- New untracked subsystems: `permissions_handlers.go` (RBAC sharing UI), `tags.js`/`tag.gohtml` (tagging), `keyboard-nav.js`, `mobile.js`, `archive.js`
|
- Nested comment replies, whole-thread resolution, and resolved-history UI are active.
|
||||||
|
- Notification dispatch rechecks the watcher's current document permission.
|
||||||
|
- Existing users can add another passkey from their account page.
|
||||||
|
- Resource permission UI and private-by-default filtering cover documents, folders, navigation, search, API document lists, and native sync snapshots.
|
||||||
|
- CodeMirror editor, wiki-link autocomplete, keyboard navigation, mobile drawers, archive UI, and tag pages are present.
|
||||||
|
- The macOS menu-bar app and CLI compile successfully.
|
||||||
|
|
||||||
## Milestone Progress
|
## Milestone Progress
|
||||||
|
|
||||||
- [x] Milestone 1: Foundation
|
- [x] Milestone 1: Foundation
|
||||||
- [x] Milestone 2: Sync Protocol
|
- [x] Milestone 2: Sync Protocol
|
||||||
- [x] Milestone 3: Authentication
|
- [x] Milestone 3: Authentication
|
||||||
- [ ] Milestone 4: Collaboration (comments UI wired; email integration in progress; digest/preferences pending)
|
- [ ] Milestone 4: Collaboration (threaded comments, notification bell, watch controls, and queued Postmark/Cloudflare/SMTP delivery shipped; preferences, digest, inbound replies, mention/conflict triggers, and live-provider tests remain)
|
||||||
- [ ] Milestone 5: Search & UI (server FTS5 done; design-system CSS, keyboard nav, mobile responsive shipped; `/design` route, Flexsearch, a11y audits pending)
|
- [ ] Milestone 5: Search & UI (server FTS5, tags, keyboard navigation, and responsive foundations shipped; offline client search, `/design`, performance, and accessibility validation remain)
|
||||||
- [ ] Milestone 6: Production (not started)
|
- [ ] Milestone 6: Production (health/readiness endpoints, CI, deployment scaffolding, and some integration tests exist; hardening acceptance criteria remain open)
|
||||||
|
|
||||||
**Current Focus:** M4 Email Integration (Postmark adapter, templates, queue with retry)
|
## Current Focus
|
||||||
|
|
||||||
## Untracked Subsystems (not in original plan)
|
Milestone 4 collaboration completion:
|
||||||
|
|
||||||
These shipped but are not milestone-tracked. Tracked via `notes/untracked-subsystems.md`:
|
- Add notification preferences and digest delivery.
|
||||||
|
- Add inbound email replies through a verified webhook flow.
|
||||||
|
- Add mention and conflict notification triggers.
|
||||||
|
- Test watcher-to-email delivery against live Postmark and Cloudflare accounts.
|
||||||
|
|
||||||
- **Permissions / RBAC sharing UI** — `permissions_handlers.go` (431 lines), `permissions.gohtml`, `permissions.js`. Per-resource read/write/admin grants.
|
## Known Gaps Requiring Human Review
|
||||||
- **Tagging** — `tags.js`, `tag.gohtml`. Document tags.
|
|
||||||
- **CodeMirror inline editor** — `editor.js` (+620), `codemirror.bundle.js`, link autocomplete.
|
|
||||||
- **macOS menu-bar app** — 14 Swift files, live search popover, drag-drop target. Beyond web-first plan scope.
|
|
||||||
- **API tokens** — `token_create.gohtml`, `auth.APIKey` types.
|
|
||||||
- **Password reset flow** — `password_reset.gohtml`, `auth.PasswordResetToken` repository methods.
|
|
||||||
|
|
||||||
## Notes for Human Review
|
- Go 1.24.2 is below the patched versions reported by `govulncheck`, including fixes for `html/template`, `crypto/tls`, `net/http`, and `crypto/x509` vulnerabilities.
|
||||||
|
- WebSocket broadcasts are authenticated but not filtered by per-document permissions.
|
||||||
- `collaboration.Service` defines its own `EmailSender` interface duplicating `email.Sender`. Consolidate when convenient.
|
- Content-hash downloads are not tied back to effective document permission.
|
||||||
- `Service.notifyWatchersOfComment` and `NotifyDocumentChanged` create in-app notifications only; never invoke `s.email`. `Notification.EmailedAt` field unused.
|
- Browser caches are not cleared on logout.
|
||||||
- `email.SMTPSender` uses unauthenticated SMTP (fine for local Mailpit, not Postmark).
|
- Raw HTML is enabled in authored Markdown while the security specification says it should be disabled.
|
||||||
|
- Folder/collection permission administration is only fully discoverable for documents.
|
||||||
|
- The implementation uses JSON for SimpleSync while protocol documentation still leaves JSON versus protobuf unresolved.
|
||||||
|
- Backup documentation does not match the named data volume in Compose.
|
||||||
|
|
||||||
---
|
---
|
||||||
*This report updated 2026-06-24 by hand. Next refresh when M4 email work lands.*
|
*This report is maintained from the implementation and verification results, not milestone checkbox state alone.*
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ Accepted
|
|||||||
The platform needs to send email notifications for: file changes (to watchers), new comments, conflict alerts, and digest summaries. It must also receive email replies and convert them to comments. Requirement: use Postmark email gateway with plain-text only.
|
The platform needs to send email notifications for: file changes (to watchers), new comments, conflict alerts, and digest summaries. It must also receive email replies and convert them to comments. Requirement: use Postmark email gateway with plain-text only.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
**Postmark HTTP API for outgoing email, Postmark inbound webhook for incoming email.**
|
**Postmark or Cloudflare Email Service HTTP API for outgoing email, with a
|
||||||
|
provider-specific verified inbound webhook for incoming email.**
|
||||||
|
|
||||||
|
Amended 2026-07-22: Cloudflare Email Service is supported as an alternative
|
||||||
|
transactional outbound provider. The same durable queue and plain-text
|
||||||
|
templates are shared by Postmark, Cloudflare, and local SMTP. Cloudflare-hosted
|
||||||
|
Workers would normally use an Email binding, but Cairnquire is a standalone Go
|
||||||
|
application and therefore uses the authenticated REST API.
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
|
|||||||
@@ -97,12 +97,14 @@ Cairnquire is a self-hosted documentation platform. A single Go binary serves HT
|
|||||||
- Client syncs index to IndexedDB for offline search
|
- Client syncs index to IndexedDB for offline search
|
||||||
- Index rebuilds incrementally on file changes
|
- Index rebuilds incrementally on file changes
|
||||||
|
|
||||||
### 7. Email (Postmark)
|
### 7. Email (Postmark or Cloudflare Email Service)
|
||||||
- Go adapter using Postmark HTTP API
|
- Go adapters use the Postmark or Cloudflare Email Sending REST API
|
||||||
|
- Local development uses SMTP with Mailpit
|
||||||
|
- A durable SQLite queue handles asynchronous delivery and bounded retries
|
||||||
- Plain-text emails only (no HTML)
|
- Plain-text emails only (no HTML)
|
||||||
- Threading via Message-ID / In-Reply-To headers
|
- Planned: threading via Message-ID / In-Reply-To headers
|
||||||
- Webhook endpoint receives email replies, converts to comments
|
- Planned: verified inbound webhook converts email replies to comments
|
||||||
- Templates: notification, digest, conflict alert
|
- Implemented templates: file change, comment, mention, and conflict; digest remains planned
|
||||||
|
|
||||||
### 8. File Store (Content-Addressed)
|
### 8. File Store (Content-Addressed)
|
||||||
- Files stored by SHA-256 hash: `store/ab/cd/abcdef1234...`
|
- Files stored by SHA-256 hash: `store/ab/cd/abcdef1234...`
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
- [x] Comment system (backend + UI wired)
|
- [x] Comment system (backend + UI wired)
|
||||||
- [x] Comment creation endpoint (web)
|
- [x] Comment creation endpoint (web)
|
||||||
- [x] Comment threading (parent/child) - schema supports it
|
- [x] Comment threading (parent/child) with nested browser replies
|
||||||
- [x] Line/section anchoring (hash-based)
|
- [x] Line/section anchoring (hash-based)
|
||||||
- [x] Comment resolution (mark as resolved)
|
- [x] Whole-thread resolution with resolved-history view
|
||||||
- [x] Comment display in the Go-served browser UI (`c39c50b` dropdown menu + comment side panel, `comments.js` +330 lines)
|
- [x] Comment display in the Go-served browser UI (`c39c50b` dropdown menu + comment side panel, `comments.js` +330 lines)
|
||||||
|
|
||||||
- [x] Notification system (backend complete)
|
- [x] Notification system (backend complete)
|
||||||
@@ -21,10 +21,10 @@
|
|||||||
- [ ] Global notification settings
|
- [ ] Global notification settings
|
||||||
- [ ] Digest mode (hourly/daily batches)
|
- [ ] Digest mode (hourly/daily batches)
|
||||||
|
|
||||||
- [x] Web notification UI (backend complete, JS partial)
|
- [x] Web notification UI
|
||||||
- [x] Notification bell with unread count - API exists
|
- [x] Notification bell with unread count
|
||||||
- [x] Notification list dropdown - API exists
|
- [x] Notification list dropdown
|
||||||
- [x] Mark as read functionality - API exists
|
- [x] Mark one/all as read functionality
|
||||||
- [ ] Notification preferences page
|
- [ ] Notification preferences page
|
||||||
|
|
||||||
### Week 8: Email Integration & Conflict Resolution
|
### Week 8: Email Integration & Conflict Resolution
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
- [x] Postmark adapter (`email/postmark.go`)
|
- [x] Postmark adapter (`email/postmark.go`)
|
||||||
- [x] Plain-text email templates (`email/templates.go`)
|
- [x] Plain-text email templates (`email/templates.go`)
|
||||||
- [x] Outgoing email queue with retry (`email/queue.go` + migration 000016)
|
- [x] Outgoing email queue with retry (`email/queue.go` + migration 000016)
|
||||||
|
- [x] Cloudflare Email Service REST adapter (`email/cloudflare.go`)
|
||||||
- [ ] Webhook endpoint for inbound email
|
- [ ] Webhook endpoint for inbound email
|
||||||
|
|
||||||
- [x] Email notification types
|
- [x] Email notification types
|
||||||
@@ -60,20 +61,20 @@
|
|||||||
### Functional
|
### Functional
|
||||||
- [x] Users can add comments to specific lines in a document (API exists)
|
- [x] Users can add comments to specific lines in a document (API exists)
|
||||||
- [x] Comments are linked to document version (hash)
|
- [x] Comments are linked to document version (hash)
|
||||||
- [x] Comment threading works (reply to reply) (schema supports it)
|
- [x] Comment threading works (reply to reply) in the browser and API
|
||||||
- [x] Users can watch files or folders for changes (API exists)
|
- [x] Users can watch files or folders for changes (document UI + APIs)
|
||||||
- [x] Email notifications sent within 1 minute of event (Postmark adapter + queue with retry)
|
- [x] Email notifications queued within 1 minute of event (Postmark, Cloudflare, or SMTP sender)
|
||||||
- [ ] Replying to notification email creates a comment
|
- [ ] Replying to notification email creates a comment
|
||||||
- [ ] Digest emails batch notifications by time window
|
- [ ] Digest emails batch notifications by time window
|
||||||
- [x] Conflicts display side-by-side diff (M2 sync)
|
- [x] Conflicts display a unified diff (M2 sync)
|
||||||
- [x] Users can resolve conflicts via web UI (M2 sync)
|
- [x] Users can resolve conflicts via web UI (M2 sync)
|
||||||
- [ ] Resolved comments are hidden but accessible in history
|
- [x] Resolved comments are hidden but accessible in history
|
||||||
|
|
||||||
### Non-Functional
|
### Non-Functional
|
||||||
- [x] Emails are plain-text only (no HTML) — enforced by templates, verified by test
|
- [x] Emails are plain-text only (no HTML) — enforced by templates, verified by test
|
||||||
- [ ] Email threading works in Gmail, Outlook, Apple Mail
|
- [ ] Email threading works in Gmail, Outlook, Apple Mail
|
||||||
- [ ] Webhook signature verified for inbound email
|
- [ ] Webhook signature verified for inbound email
|
||||||
- [x] Failed emails retried 3 times with exponential backoff — queue.go, verified by test
|
- [x] Temporary email failures retried 3 times with exponential backoff; permanent provider rejections fail immediately
|
||||||
- [x] Email queue doesn't block web requests (async processing) — worker runs in goroutine
|
- [x] Email queue doesn't block web requests (async processing) — worker runs in goroutine
|
||||||
|
|
||||||
### Email Template Examples
|
### Email Template Examples
|
||||||
@@ -166,7 +167,7 @@ CREATE TABLE watchers (
|
|||||||
|
|
||||||
- [ ] All acceptance criteria pass
|
- [ ] All acceptance criteria pass
|
||||||
- [ ] Email flows tested with real Postmark account
|
- [ ] Email flows tested with real Postmark account
|
||||||
- [ ] Comment threading tested with 3+ levels
|
- [x] Comment threading tested with 3+ levels
|
||||||
- [ ] Conflict resolution tested with 2+ concurrent editors
|
- [ ] Conflict resolution tested with 2+ concurrent editors
|
||||||
- [ ] Notification preferences persist across sessions
|
- [ ] Notification preferences persist across sessions
|
||||||
- [ ] Digest mode tested with 24-hour window
|
- [ ] Digest mode tested with 24-hour window
|
||||||
|
|||||||
@@ -150,19 +150,19 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
|
|||||||
**Goal:** Comments, notifications, and conflict resolution
|
**Goal:** Comments, notifications, and conflict resolution
|
||||||
|
|
||||||
- [x] Comment system linked to file versions (by hash) - backend complete
|
- [x] Comment system linked to file versions (by hash) - backend complete
|
||||||
- [ ] Email notifications via Postmark adapter - SMTP stub exists
|
- [x] Queued email notifications via Postmark, Cloudflare Email Service, or local SMTP
|
||||||
- [x] Web-based conflict resolution UI (Milestone 2)
|
- [x] Web-based conflict resolution UI (Milestone 2)
|
||||||
- [ ] Notification preferences (per-file, per-folder, global, digest)
|
- [ ] Notification preferences (per-file, per-folder, global, digest)
|
||||||
- [x] Comment threading schema - UI needs wiring
|
- [x] Comment threading schema and browser reply UI
|
||||||
- [ ] Email replies create comments
|
- [ ] Email replies create comments
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
|
|
||||||
- [x] Users can comment on specific lines/sections of a document (API)
|
- [x] Users can comment on specific lines/sections of a document (API)
|
||||||
- [x] Comments persist and are linked to document versions
|
- [x] Comments persist and are linked to document versions
|
||||||
- [ ] Email notifications sent for watched file changes (NoOp sender)
|
- [x] Email notifications queued for watched file changes with retry
|
||||||
- [ ] Replying to notification email creates a comment
|
- [ ] Replying to notification email creates a comment
|
||||||
- [x] Conflicts display side-by-side diff with resolution actions (M2)
|
- [x] Conflicts display a unified diff with resolution actions (M2)
|
||||||
- [ ] Digest mode batches notifications by time window
|
- [ ] Digest mode batches notifications by time window
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -265,7 +265,7 @@ See [architecture-overview.md](architecture-overview.md) for detailed component
|
|||||||
| Markdown | `github.com/yuin/goldmark` + extensions | Fast, extensible, CommonMark compliant |
|
| Markdown | `github.com/yuin/goldmark` + extensions | Fast, extensible, CommonMark compliant |
|
||||||
| WebSocket | `github.com/gorilla/websocket` | Most widely used Go WS library |
|
| WebSocket | `github.com/gorilla/websocket` | Most widely used Go WS library |
|
||||||
| File Watch | `github.com/fsnotify/fsnotify` | Cross-platform, stdlib-adjacent |
|
| File Watch | `github.com/fsnotify/fsnotify` | Cross-platform, stdlib-adjacent |
|
||||||
| Email | `github.com/keighl/postmark` + `net/smtp` | Postmark adapter + SMTP fallback |
|
| Email | `net/http` + `net/smtp` | Postmark/Cloudflare REST adapters + local SMTP |
|
||||||
| Crypto | `crypto/ed25519`, `crypto/sha256` | Standard library, no external deps |
|
| Crypto | `crypto/ed25519`, `crypto/sha256` | Standard library, no external deps |
|
||||||
| Templates | `html/template` | Stdlib, auto-escaping, SSR-safe |
|
| Templates | `html/template` | Stdlib, auto-escaping, SSR-safe |
|
||||||
|
|
||||||
@@ -371,7 +371,7 @@ cairnquire/
|
|||||||
| WebAuthn/passkey adoption issues | Medium | Medium | Password fallback always available; extensive browser testing |
|
| WebAuthn/passkey adoption issues | Medium | Medium | Password fallback always available; extensive browser testing |
|
||||||
| SQLite performance at scale | Low | High | libsql replication path; FTS5 for search; benchmark early |
|
| SQLite performance at scale | Low | High | libsql replication path; FTS5 for search; benchmark early |
|
||||||
| Markdown extension parser bugs | Medium | Medium | Fuzz testing; strict CommonMark base; gradual feature rollout |
|
| Markdown extension parser bugs | Medium | Medium | Fuzz testing; strict CommonMark base; gradual feature rollout |
|
||||||
| Email deliverability | Medium | Medium | Postmark reputation monitoring; SPF/DKIM setup; SMTP fallback |
|
| Email deliverability | Medium | Medium | Provider reputation monitoring; SPF/DKIM/DMARC setup; provider fallback |
|
||||||
| Frontend bundle bloat | Medium | Medium | Bundle analysis in CI; tree-shaking enforced; lazy loading |
|
| Frontend bundle bloat | Medium | Medium | Bundle analysis in CI; tree-shaking enforced; lazy loading |
|
||||||
| File sync conflicts in teams | High | Medium | Clear UX for conflict resolution; email notification; automatic merge when possible |
|
| File sync conflicts in teams | High | Medium | Clear UX for conflict resolution; email notification; automatic merge when possible |
|
||||||
|
|
||||||
@@ -383,11 +383,9 @@ cairnquire/
|
|||||||
2. ✅ Complete Milestone 2: Sync Protocol
|
2. ✅ Complete Milestone 2: Sync Protocol
|
||||||
3. ✅ Complete Milestone 3: Authentication
|
3. ✅ Complete Milestone 3: Authentication
|
||||||
4. 🔄 Finish Milestone 4: Collaboration
|
4. 🔄 Finish Milestone 4: Collaboration
|
||||||
- Wire up comment UI in browser (JS exists, needs integration)
|
- Complete inbound email replies, mention notifications, and conflict notifications
|
||||||
- Wire up notification bell UI
|
- Add global/per-resource notification preferences and digest scheduling
|
||||||
- Implement actual email sending (Postmark or SMTP)
|
- Test watcher-driven notifications against live Postmark and Cloudflare accounts
|
||||||
- Add notification preferences page
|
|
||||||
- Test watcher-driven notifications end-to-end
|
|
||||||
5. 🔄 Finish Milestone 5: Search & UI Polish
|
5. 🔄 Finish Milestone 5: Search & UI Polish
|
||||||
- Tag filtering in search
|
- Tag filtering in search
|
||||||
- Design system /design route
|
- Design system /design route
|
||||||
@@ -395,11 +393,11 @@ cairnquire/
|
|||||||
- Mobile-responsive layout polish
|
- Mobile-responsive layout polish
|
||||||
- Accessibility improvements
|
- Accessibility improvements
|
||||||
6. ⏳ Milestone 6: Production Hardening
|
6. ⏳ Milestone 6: Production Hardening
|
||||||
- Increase test coverage from 39% to 80%+
|
- Increase test coverage from 52.4% to 80%+
|
||||||
- Add integration tests for auth, sync, collaboration
|
- Add integration tests for auth, sync, collaboration
|
||||||
- Security audit checklist
|
- Security audit checklist
|
||||||
- Backup/restore scripts
|
- Backup/restore scripts
|
||||||
- Health check and monitoring endpoints
|
- Complete dependency health checks and add monitoring endpoints
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,12 @@ deployment, only `CAIRNQUIRE_PUBLIC_ORIGIN` must be supplied in `.env`.
|
|||||||
| Variable | Required | Compose default | Description |
|
| Variable | Required | Compose default | Description |
|
||||||
|----------|----------|-----------------|-------------|
|
|----------|----------|-----------------|-------------|
|
||||||
| `CAIRNQUIRE_PUBLIC_ORIGIN` | Yes | None | Exact external HTTPS origin. Passkeys and device-flow URLs depend on this value. |
|
| `CAIRNQUIRE_PUBLIC_ORIGIN` | Yes | None | Exact external HTTPS origin. Passkeys and device-flow URLs depend on this value. |
|
||||||
|
| `CAIRNQUIRE_EMAIL_PROVIDER` | No | `smtp` | Outbound provider: `smtp`, `postmark`, `cloudflare`, or `noop`. |
|
||||||
| `CAIRNQUIRE_EMAIL_SMTP_HOST` | No | `mailpit` | SMTP server used for notifications. |
|
| `CAIRNQUIRE_EMAIL_SMTP_HOST` | No | `mailpit` | SMTP server used for notifications. |
|
||||||
| `CAIRNQUIRE_EMAIL_SMTP_PORT` | No | `1025` | SMTP server port. |
|
| `CAIRNQUIRE_EMAIL_SMTP_PORT` | No | `1025` | SMTP server port. |
|
||||||
|
| `CAIRNQUIRE_EMAIL_POSTMARK_TOKEN` | For Postmark | Empty | Postmark server token. |
|
||||||
|
| `CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID` | For Cloudflare | Empty | Account with Cloudflare Email Sending enabled. |
|
||||||
|
| `CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN` | For Cloudflare | Empty | API token with Email Sending permission. Store it as a deployment secret. |
|
||||||
| `CAIRNQUIRE_EMAIL_FROM` | No | `notifications@cairnquire.local` | Notification sender address. |
|
| `CAIRNQUIRE_EMAIL_FROM` | No | `notifications@cairnquire.local` | Notification sender address. |
|
||||||
| `CAIRNQUIRE_LOG_LEVEL` | No | `INFO` | Application log level. |
|
| `CAIRNQUIRE_LOG_LEVEL` | No | `INFO` | Application log level. |
|
||||||
|
|
||||||
@@ -72,6 +76,23 @@ checked-in Compose deployment:
|
|||||||
| `CAIRNQUIRE_CONFIG` | Empty | Optional JSON configuration file. Environment variables override it. |
|
| `CAIRNQUIRE_CONFIG` | Empty | Optional JSON configuration file. Environment variables override it. |
|
||||||
| `CAIRNQUIRE_DEV_MODE` | `false` | Local-only auth shortcut. Never enable this on a deployed server. |
|
| `CAIRNQUIRE_DEV_MODE` | `false` | Local-only auth shortcut. Never enable this on a deployed server. |
|
||||||
|
|
||||||
|
### Cloudflare Email Service
|
||||||
|
|
||||||
|
Cairnquire is an external Go application, so it uses Cloudflare Email
|
||||||
|
Service's REST API rather than a Workers binding. Before enabling the provider:
|
||||||
|
|
||||||
|
1. Enable Email Sending for the sender domain in Cloudflare, or run `npx wrangler email sending enable example.com`.
|
||||||
|
2. Create a Cloudflare API token with Email Sending permission for the account.
|
||||||
|
3. Set `CAIRNQUIRE_EMAIL_PROVIDER=cloudflare`, the account ID, API token, and a `CAIRNQUIRE_EMAIL_FROM` address on the onboarded domain.
|
||||||
|
|
||||||
|
Cloudflare Email Service is used only for transactional notifications. API
|
||||||
|
tokens must be supplied through deployment secrets and must not be committed.
|
||||||
|
The durable queue retries Cloudflare `429` and server errors; validation and
|
||||||
|
permanent-bounce responses are retained immediately as failed deliveries.
|
||||||
|
|
||||||
|
Cloudflare Email Routing for inbound comment replies is a separate Milestone 4
|
||||||
|
feature and is not enabled by these outbound settings.
|
||||||
|
|
||||||
## Persistent Data
|
## Persistent Data
|
||||||
|
|
||||||
Compose mounts:
|
Compose mounts:
|
||||||
|
|||||||
@@ -116,8 +116,12 @@ Supported variables:
|
|||||||
- `CAIRNQUIRE_DATABASE_AUTH_TOKEN` - Optional auth token for the remote libsql primary.
|
- `CAIRNQUIRE_DATABASE_AUTH_TOKEN` - Optional auth token for the remote libsql primary.
|
||||||
- `CAIRNQUIRE_CONTENT_SOURCE_DIR` - Editable Markdown source directory.
|
- `CAIRNQUIRE_CONTENT_SOURCE_DIR` - Editable Markdown source directory.
|
||||||
- `CAIRNQUIRE_CONTENT_STORE_DIR` - Content-addressed attachment store.
|
- `CAIRNQUIRE_CONTENT_STORE_DIR` - Content-addressed attachment store.
|
||||||
- `CAIRNQUIRE_EMAIL_SMTP_HOST` - SMTP host. Set to an empty string in JSON config to disable delivery.
|
- `CAIRNQUIRE_EMAIL_PROVIDER` - `smtp`, `postmark`, `cloudflare`, or `noop`. Compose defaults to `smtp` with Mailpit.
|
||||||
|
- `CAIRNQUIRE_EMAIL_SMTP_HOST` - SMTP host for local delivery.
|
||||||
- `CAIRNQUIRE_EMAIL_SMTP_PORT` - SMTP port. Defaults to `1025`.
|
- `CAIRNQUIRE_EMAIL_SMTP_PORT` - SMTP port. Defaults to `1025`.
|
||||||
|
- `CAIRNQUIRE_EMAIL_POSTMARK_TOKEN` - Postmark server token when the provider is `postmark`.
|
||||||
|
- `CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID` - Cloudflare account with Email Sending enabled.
|
||||||
|
- `CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN` - Cloudflare token with Email Sending permission.
|
||||||
- `CAIRNQUIRE_EMAIL_FROM` - Notification sender address.
|
- `CAIRNQUIRE_EMAIL_FROM` - Notification sender address.
|
||||||
- `CAIRNQUIRE_LOG_LEVEL` - Defaults to `INFO`.
|
- `CAIRNQUIRE_LOG_LEVEL` - Defaults to `INFO`.
|
||||||
- `CAIRNQUIRE_DEV_MODE` - Optional local-only auth shortcut. Do not enable on a deployed server.
|
- `CAIRNQUIRE_DEV_MODE` - Optional local-only auth shortcut. Do not enable on a deployed server.
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
|||||||
var emailQueue *email.Queue
|
var emailQueue *email.Queue
|
||||||
emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName}
|
emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName}
|
||||||
switch cfg.Email.ResolvedProvider() {
|
switch cfg.Email.ResolvedProvider() {
|
||||||
|
case "cloudflare":
|
||||||
|
cf := email.NewCloudflareSender(
|
||||||
|
cfg.Email.CloudflareAccountID,
|
||||||
|
cfg.Email.CloudflareAPIToken,
|
||||||
|
cfg.Email.From,
|
||||||
|
cfg.Email.CloudflareAPIURL,
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
emailSender = cf
|
||||||
|
authService.SetEmailSender(cf)
|
||||||
case "postmark":
|
case "postmark":
|
||||||
pm := email.NewPostmarkSender(cfg.Email.PostmarkToken, cfg.Email.From, cfg.Email.PostmarkAPIURL, logger)
|
pm := email.NewPostmarkSender(cfg.Email.PostmarkToken, cfg.Email.From, cfg.Email.PostmarkAPIURL, logger)
|
||||||
emailSender = pm
|
emailSender = pm
|
||||||
@@ -95,7 +105,22 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
|||||||
EmailQueue: emailQueue,
|
EmailQueue: emailQueue,
|
||||||
EmailRenderer: emailRenderer,
|
EmailRenderer: emailRenderer,
|
||||||
UserLookup: authRepo,
|
UserLookup: authRepo,
|
||||||
PublicOrigin: cfg.Auth.PublicOrigin,
|
CanReadDocument: func(ctx context.Context, userID, documentID string) (bool, error) {
|
||||||
|
document, err := repo.GetDocumentByID(ctx, documentID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
principal, err := authService.PrincipalForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
permission, err := authService.EffectiveResourcePermission(ctx, principal, auth.ResourceDocument, document.Path, document.Tags)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return auth.PermissionAllows(permission, auth.PermissionRead), nil
|
||||||
|
},
|
||||||
|
PublicOrigin: cfg.Auth.PublicOrigin,
|
||||||
})
|
})
|
||||||
|
|
||||||
service.OnChange(func(change docs.DocumentChange) {
|
service.OnChange(func(change docs.DocumentChange) {
|
||||||
|
|||||||
@@ -63,14 +63,17 @@ func (r *Repository) GetComment(ctx context.Context, id string) (*Comment, error
|
|||||||
return &c, nil
|
return &c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string) ([]Comment, error) {
|
func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
query := `
|
||||||
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
|
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
|
||||||
FROM comments c
|
FROM comments c
|
||||||
JOIN users u ON u.id = c.author_id
|
JOIN users u ON u.id = c.author_id
|
||||||
WHERE c.document_id = ?
|
WHERE c.document_id = ?`
|
||||||
ORDER BY c.created_at ASC
|
if !includeResolved {
|
||||||
`, documentID)
|
query += ` AND c.resolved_at IS NULL`
|
||||||
|
}
|
||||||
|
query += ` ORDER BY c.created_at ASC`
|
||||||
|
rows, err := r.db.QueryContext(ctx, query, documentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list comments: %w", err)
|
return nil, fmt.Errorf("list comments: %w", err)
|
||||||
}
|
}
|
||||||
@@ -79,14 +82,17 @@ func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID stri
|
|||||||
return scanComments(rows)
|
return scanComments(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
|
func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
query := `
|
||||||
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
|
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
|
||||||
FROM comments c
|
FROM comments c
|
||||||
JOIN users u ON u.id = c.author_id
|
JOIN users u ON u.id = c.author_id
|
||||||
WHERE c.document_id = ? AND c.anchor_hash = ? AND c.resolved_at IS NULL
|
WHERE c.document_id = ? AND c.anchor_hash = ?`
|
||||||
ORDER BY c.created_at ASC
|
if !includeResolved {
|
||||||
`, documentID, anchorHash)
|
query += ` AND c.resolved_at IS NULL`
|
||||||
|
}
|
||||||
|
query += ` ORDER BY c.created_at ASC`
|
||||||
|
rows, err := r.db.QueryContext(ctx, query, documentID, anchorHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list comments by anchor: %w", err)
|
return nil, fmt.Errorf("list comments by anchor: %w", err)
|
||||||
}
|
}
|
||||||
@@ -95,13 +101,25 @@ func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string
|
|||||||
return scanComments(rows)
|
return scanComments(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ResolveComment(ctx context.Context, commentID string, userID string) error {
|
func (r *Repository) ResolveCommentThread(ctx context.Context, commentID string, userID string) error {
|
||||||
_, err := r.db.ExecContext(ctx, `
|
result, err := r.db.ExecContext(ctx, `
|
||||||
|
WITH RECURSIVE thread(id) AS (
|
||||||
|
SELECT id FROM comments WHERE id = ? AND parent_id IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.id FROM comments c JOIN thread t ON c.parent_id = t.id
|
||||||
|
)
|
||||||
UPDATE comments SET resolved_at = ?, resolved_by = ?
|
UPDATE comments SET resolved_at = ?, resolved_by = ?
|
||||||
WHERE id = ?
|
WHERE id IN (SELECT id FROM thread) AND resolved_at IS NULL
|
||||||
`, formatTime(time.Now().UTC()), userID, commentID)
|
`, commentID, formatTime(time.Now().UTC()), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve comment: %w", err)
|
return fmt.Errorf("resolve comment thread: %w", err)
|
||||||
|
}
|
||||||
|
changed, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve comment thread result: %w", err)
|
||||||
|
}
|
||||||
|
if changed == 0 {
|
||||||
|
return fmt.Errorf("comment thread not found or already resolved")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -228,10 +246,12 @@ func (r *Repository) MarkAllNotificationsRead(ctx context.Context, userID string
|
|||||||
// Watchers
|
// Watchers
|
||||||
|
|
||||||
func (r *Repository) AddWatcher(ctx context.Context, w Watcher) error {
|
func (r *Repository) AddWatcher(ctx context.Context, w Watcher) error {
|
||||||
|
if (w.DocumentID == nil) == (w.FolderPath == nil) {
|
||||||
|
return fmt.Errorf("watcher requires exactly one target")
|
||||||
|
}
|
||||||
_, err := r.db.ExecContext(ctx, `
|
_, err := r.db.ExecContext(ctx, `
|
||||||
INSERT INTO watchers (user_id, document_id, folder_path, created_at)
|
INSERT OR IGNORE INTO watchers (user_id, document_id, folder_path, created_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT(user_id, document_id, folder_path) DO NOTHING
|
|
||||||
`, w.UserID, w.DocumentID, w.FolderPath, formatTime(w.CreatedAt))
|
`, w.UserID, w.DocumentID, w.FolderPath, formatTime(w.CreatedAt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("add watcher: %w", err)
|
return fmt.Errorf("add watcher: %w", err)
|
||||||
@@ -262,12 +282,12 @@ func (r *Repository) ListWatchersByDocument(ctx context.Context, documentID stri
|
|||||||
return scanWatchers(rows)
|
return scanWatchers(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath string) ([]Watcher, error) {
|
func (r *Repository) ListWatchersByFolder(ctx context.Context, documentPath string) ([]Watcher, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT user_id, document_id, folder_path, created_at
|
SELECT user_id, document_id, folder_path, created_at
|
||||||
FROM watchers
|
FROM watchers
|
||||||
WHERE folder_path = ? OR ? || '/' = substr(folder_path, 1, length(?) + 1)
|
WHERE folder_path = '' OR folder_path || '/' = substr(?, 1, length(folder_path) + 1)
|
||||||
`, folderPath, folderPath, folderPath)
|
`, documentPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list folder watchers: %w", err)
|
return nil, fmt.Errorf("list folder watchers: %w", err)
|
||||||
}
|
}
|
||||||
@@ -275,6 +295,24 @@ func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath string
|
|||||||
return scanWatchers(rows)
|
return scanWatchers(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListWatchersForDocument(ctx context.Context, documentID string) ([]Watcher, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT DISTINCT w.user_id, w.document_id, w.folder_path, w.created_at
|
||||||
|
FROM watchers w
|
||||||
|
JOIN documents d ON d.id = ?
|
||||||
|
WHERE w.document_id = d.id
|
||||||
|
OR (w.folder_path IS NOT NULL AND (
|
||||||
|
w.folder_path = ''
|
||||||
|
OR w.folder_path || '/' = substr(d.path, 1, length(w.folder_path) + 1)
|
||||||
|
))
|
||||||
|
`, documentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list watchers for document: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanWatchers(rows)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) IsWatching(ctx context.Context, userID string, documentID string) (bool, error) {
|
func (r *Repository) IsWatching(ctx context.Context, userID string, documentID string) (bool, error) {
|
||||||
var count int
|
var count int
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
|||||||
157
apps/server/internal/collaboration/repository_test.go
Normal file
157
apps/server/internal/collaboration/repository_test.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package collaboration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newCollaborationTestRepository(t *testing.T) (*Repository, *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := database.Open(ctx, config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "test.sqlite")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
if err := database.ApplyMigrations(ctx, db.SQL()); err != nil {
|
||||||
|
t.Fatalf("apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
now := formatTime(time.Now().UTC())
|
||||||
|
for _, user := range []struct{ id, email, name string }{
|
||||||
|
{"user:author", "author@example.com", "Author"},
|
||||||
|
{"user:watcher", "watcher@example.com", "Watcher"},
|
||||||
|
} {
|
||||||
|
if _, err := db.SQL().ExecContext(ctx, `
|
||||||
|
INSERT INTO users (id, email, display_name, role, created_at) VALUES (?, ?, ?, 'editor', ?)
|
||||||
|
`, user.id, user.email, user.name, now); err != nil {
|
||||||
|
t.Fatalf("insert user: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, document := range []struct{ id, path, title string }{
|
||||||
|
{"doc:guide/setup", "guide/setup.md", "Setup"},
|
||||||
|
{"doc:other", "other.md", "Other"},
|
||||||
|
} {
|
||||||
|
if _, err := db.SQL().ExecContext(ctx, `
|
||||||
|
INSERT INTO documents (id, path, current_hash, title, tags, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'hash', ?, '[]', ?, ?)
|
||||||
|
`, document.id, document.path, document.title, now, now); err != nil {
|
||||||
|
t.Fatalf("insert document: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NewRepository(db.SQL()), db.SQL()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatcherTargetsAreIdempotentAndFolderMatchingUsesAncestors(t *testing.T) {
|
||||||
|
repo, db := newCollaborationTestRepository(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
documentID := "doc:guide/setup"
|
||||||
|
folder := "guide"
|
||||||
|
for range 2 {
|
||||||
|
if err := repo.AddWatcher(ctx, Watcher{UserID: "user:watcher", DocumentID: &documentID, CreatedAt: now}); err != nil {
|
||||||
|
t.Fatalf("add document watcher: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.AddWatcher(ctx, Watcher{UserID: "user:watcher", FolderPath: &folder, CreatedAt: now}); err != nil {
|
||||||
|
t.Fatalf("add folder watcher: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM watchers`).Scan(&count); err != nil {
|
||||||
|
t.Fatalf("count watchers: %v", err)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("watcher count = %d, want 2", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"guide/setup.md", 1},
|
||||||
|
{"guide/deep/setup.md", 1},
|
||||||
|
{"guidebook/setup.md", 0},
|
||||||
|
{"other.md", 0},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
watchers, err := repo.ListWatchersByFolder(ctx, test.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWatchersByFolder(%q): %v", test.path, err)
|
||||||
|
}
|
||||||
|
if len(watchers) != test.want {
|
||||||
|
t.Errorf("ListWatchersByFolder(%q) = %d, want %d", test.path, len(watchers), test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentReplyValidationHistoryAndThreadResolution(t *testing.T) {
|
||||||
|
repo, _ := newCollaborationTestRepository(t)
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
service := NewServiceWithOptions(repo, nil, logger, Options{})
|
||||||
|
ctx := context.Background()
|
||||||
|
principal := auth.Principal{UserID: "user:author", DisplayName: "Author"}
|
||||||
|
anchor := "anchor"
|
||||||
|
|
||||||
|
root, err := service.CreateComment(ctx, principal, CreateCommentInput{
|
||||||
|
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Root", AnchorHash: &anchor,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create root: %v", err)
|
||||||
|
}
|
||||||
|
reply, err := service.CreateComment(ctx, principal, CreateCommentInput{
|
||||||
|
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Reply", ParentID: &root.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create reply: %v", err)
|
||||||
|
}
|
||||||
|
if reply.AnchorHash == nil || *reply.AnchorHash != anchor {
|
||||||
|
t.Fatalf("reply anchor = %#v, want inherited %q", reply.AnchorHash, anchor)
|
||||||
|
}
|
||||||
|
grandchild, err := service.CreateComment(ctx, principal, CreateCommentInput{
|
||||||
|
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Grandchild", ParentID: &reply.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create grandchild: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.CreateComment(ctx, principal, CreateCommentInput{
|
||||||
|
DocumentID: "doc:other", VersionHash: "hash", Content: "Wrong document", ParentID: &root.ID,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("cross-document reply should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.ResolveComment(ctx, principal, root.ID); err != nil {
|
||||||
|
t.Fatalf("resolve thread: %v", err)
|
||||||
|
}
|
||||||
|
active, err := service.ListCommentsByAnchor(ctx, "doc:guide/setup", anchor, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list active: %v", err)
|
||||||
|
}
|
||||||
|
if len(active) != 0 {
|
||||||
|
t.Fatalf("active comments = %d, want 0", len(active))
|
||||||
|
}
|
||||||
|
history, err := service.ListCommentsByAnchor(ctx, "doc:guide/setup", anchor, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list history: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 3 {
|
||||||
|
t.Fatalf("history comments = %d, want 3", len(history))
|
||||||
|
}
|
||||||
|
for _, comment := range history {
|
||||||
|
if comment.ResolvedAt == nil {
|
||||||
|
t.Errorf("comment %s was not resolved", comment.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := service.CreateComment(ctx, principal, CreateCommentInput{
|
||||||
|
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Too late", ParentID: &grandchild.ID,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("reply to resolved thread should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
@@ -50,6 +52,8 @@ type UserLookup interface {
|
|||||||
GetUserByID(ctx context.Context, userID string) (auth.User, error)
|
GetUserByID(ctx context.Context, userID string) (auth.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DocumentReadAuthorizer func(ctx context.Context, userID, documentID string) (bool, error)
|
||||||
|
|
||||||
type NoOpEmailSender struct{}
|
type NoOpEmailSender struct{}
|
||||||
|
|
||||||
func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
|
func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
@@ -58,22 +62,24 @@ func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body s
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo *Repository
|
repo *Repository
|
||||||
email EmailSender
|
email EmailSender
|
||||||
queue EmailQueue
|
queue EmailQueue
|
||||||
renderer EmailRenderer
|
renderer EmailRenderer
|
||||||
users UserLookup
|
users UserLookup
|
||||||
hub *realtime.Hub
|
canReadDocument DocumentReadAuthorizer
|
||||||
logger *slog.Logger
|
hub *realtime.Hub
|
||||||
publicOrigin string
|
logger *slog.Logger
|
||||||
|
publicOrigin string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
EmailSender EmailSender
|
EmailSender EmailSender
|
||||||
EmailQueue EmailQueue
|
EmailQueue EmailQueue
|
||||||
EmailRenderer EmailRenderer
|
EmailRenderer EmailRenderer
|
||||||
UserLookup UserLookup
|
UserLookup UserLookup
|
||||||
PublicOrigin string
|
CanReadDocument DocumentReadAuthorizer
|
||||||
|
PublicOrigin string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
|
func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
|
||||||
@@ -89,14 +95,15 @@ func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Log
|
|||||||
opts.EmailSender = &NoOpEmailSender{}
|
opts.EmailSender = &NoOpEmailSender{}
|
||||||
}
|
}
|
||||||
return &Service{
|
return &Service{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
email: opts.EmailSender,
|
email: opts.EmailSender,
|
||||||
queue: opts.EmailQueue,
|
queue: opts.EmailQueue,
|
||||||
renderer: opts.EmailRenderer,
|
renderer: opts.EmailRenderer,
|
||||||
users: opts.UserLookup,
|
users: opts.UserLookup,
|
||||||
hub: hub,
|
canReadDocument: opts.CanReadDocument,
|
||||||
logger: logger,
|
hub: hub,
|
||||||
publicOrigin: opts.PublicOrigin,
|
logger: logger,
|
||||||
|
publicOrigin: opts.PublicOrigin,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,12 +113,35 @@ func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, i
|
|||||||
if principal.UserID == "" {
|
if principal.UserID == "" {
|
||||||
return nil, fmt.Errorf("authentication required")
|
return nil, fmt.Errorf("authentication required")
|
||||||
}
|
}
|
||||||
|
input.Content = strings.TrimSpace(input.Content)
|
||||||
if input.Content == "" {
|
if input.Content == "" {
|
||||||
return nil, fmt.Errorf("content is required")
|
return nil, fmt.Errorf("content is required")
|
||||||
}
|
}
|
||||||
if input.DocumentID == "" {
|
if input.DocumentID == "" {
|
||||||
return nil, fmt.Errorf("document_id is required")
|
return nil, fmt.Errorf("document_id is required")
|
||||||
}
|
}
|
||||||
|
if input.VersionHash == "" {
|
||||||
|
return nil, fmt.Errorf("version_hash is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.ParentID != nil && *input.ParentID != "" {
|
||||||
|
parent, err := s.repo.GetComment(ctx, *input.ParentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parent comment not found")
|
||||||
|
}
|
||||||
|
if parent.DocumentID != input.DocumentID {
|
||||||
|
return nil, fmt.Errorf("parent comment belongs to another document")
|
||||||
|
}
|
||||||
|
if parent.ResolvedAt != nil {
|
||||||
|
return nil, fmt.Errorf("cannot reply to a resolved thread")
|
||||||
|
}
|
||||||
|
if input.AnchorHash == nil {
|
||||||
|
input.AnchorHash = parent.AnchorHash
|
||||||
|
}
|
||||||
|
if input.AnchorLine == nil {
|
||||||
|
input.AnchorLine = parent.AnchorLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
comment := Comment{
|
comment := Comment{
|
||||||
ID: randomID("comment"),
|
ID: randomID("comment"),
|
||||||
@@ -135,23 +165,27 @@ func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, i
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create notifications for watchers
|
created, err := s.repo.GetComment(ctx, comment.ID)
|
||||||
if err := s.notifyWatchersOfComment(ctx, comment); err != nil {
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.notifyWatchersOfComment(ctx, *created); err != nil {
|
||||||
s.logger.Warn("failed to notify watchers of comment", "error", err)
|
s.logger.Warn("failed to notify watchers of comment", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast to connected clients
|
// Broadcast to connected clients
|
||||||
s.broadcastNotification(principal.UserID)
|
s.broadcastNotification(principal.UserID)
|
||||||
|
|
||||||
return s.repo.GetComment(ctx, comment.ID)
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListComments(ctx context.Context, documentID string) ([]Comment, error) {
|
func (s *Service) ListComments(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) {
|
||||||
return s.repo.ListCommentsByDocument(ctx, documentID)
|
return s.repo.ListCommentsByDocument(ctx, documentID, includeResolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
|
func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) {
|
||||||
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash)
|
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash, includeResolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetComment(ctx context.Context, commentID string) (*Comment, error) {
|
func (s *Service) GetComment(ctx context.Context, commentID string) (*Comment, error) {
|
||||||
@@ -162,7 +196,14 @@ func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal,
|
|||||||
if principal.UserID == "" {
|
if principal.UserID == "" {
|
||||||
return fmt.Errorf("authentication required")
|
return fmt.Errorf("authentication required")
|
||||||
}
|
}
|
||||||
return s.repo.ResolveComment(ctx, commentID, principal.UserID)
|
comment, err := s.repo.GetComment(ctx, commentID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("comment not found")
|
||||||
|
}
|
||||||
|
if comment.ParentID != nil {
|
||||||
|
return fmt.Errorf("only a thread root can be resolved")
|
||||||
|
}
|
||||||
|
return s.repo.ResolveCommentThread(ctx, commentID, principal.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
@@ -212,6 +253,9 @@ func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, d
|
|||||||
if principal.UserID == "" {
|
if principal.UserID == "" {
|
||||||
return fmt.Errorf("authentication required")
|
return fmt.Errorf("authentication required")
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(documentID) == "" {
|
||||||
|
return fmt.Errorf("document_id is required")
|
||||||
|
}
|
||||||
return s.repo.AddWatcher(ctx, Watcher{
|
return s.repo.AddWatcher(ctx, Watcher{
|
||||||
UserID: principal.UserID,
|
UserID: principal.UserID,
|
||||||
DocumentID: &documentID,
|
DocumentID: &documentID,
|
||||||
@@ -223,6 +267,9 @@ func (s *Service) UnwatchDocument(ctx context.Context, principal auth.Principal,
|
|||||||
if principal.UserID == "" {
|
if principal.UserID == "" {
|
||||||
return fmt.Errorf("authentication required")
|
return fmt.Errorf("authentication required")
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(documentID) == "" {
|
||||||
|
return fmt.Errorf("document_id is required")
|
||||||
|
}
|
||||||
return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil)
|
return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +277,10 @@ func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, fol
|
|||||||
if principal.UserID == "" {
|
if principal.UserID == "" {
|
||||||
return fmt.Errorf("authentication required")
|
return fmt.Errorf("authentication required")
|
||||||
}
|
}
|
||||||
|
folderPath, err := NormalizeFolderPath(folderPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return s.repo.AddWatcher(ctx, Watcher{
|
return s.repo.AddWatcher(ctx, Watcher{
|
||||||
UserID: principal.UserID,
|
UserID: principal.UserID,
|
||||||
FolderPath: &folderPath,
|
FolderPath: &folderPath,
|
||||||
@@ -241,6 +292,10 @@ func (s *Service) UnwatchFolder(ctx context.Context, principal auth.Principal, f
|
|||||||
if principal.UserID == "" {
|
if principal.UserID == "" {
|
||||||
return fmt.Errorf("authentication required")
|
return fmt.Errorf("authentication required")
|
||||||
}
|
}
|
||||||
|
folderPath, err := NormalizeFolderPath(folderPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath)
|
return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +324,9 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[w.UserID] = true
|
seen[w.UserID] = true
|
||||||
|
if !s.watcherCanRead(ctx, w.UserID, documentID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf("%s updated %s", actorName, documentPath)
|
msg := fmt.Sprintf("%s updated %s", actorName, documentPath)
|
||||||
notification := Notification{
|
notification := Notification{
|
||||||
@@ -298,15 +356,23 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error {
|
func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error {
|
||||||
watchers, err := s.repo.ListWatchersByDocument(ctx, comment.DocumentID)
|
watchers, err := s.repo.ListWatchersForDocument(ctx, comment.DocumentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
for _, w := range watchers {
|
for _, w := range watchers {
|
||||||
if w.UserID == comment.AuthorID {
|
if w.UserID == comment.AuthorID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if seen[w.UserID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[w.UserID] = true
|
||||||
|
if !s.watcherCanRead(ctx, w.UserID, comment.DocumentID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
msg := fmt.Sprintf("New comment on document")
|
msg := fmt.Sprintf("New comment on document")
|
||||||
if comment.AnchorHash != nil {
|
if comment.AnchorHash != nil {
|
||||||
msg = fmt.Sprintf("New comment on a section of the document")
|
msg = fmt.Sprintf("New comment on a section of the document")
|
||||||
@@ -325,9 +391,10 @@ func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{
|
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{
|
||||||
ActorName: comment.AuthorName,
|
ActorName: comment.AuthorName,
|
||||||
CommentBody: comment.Content,
|
DocumentPath: documentPathFromID(comment.DocumentID) + ".md",
|
||||||
ViewURL: s.documentURL(comment.DocumentID),
|
CommentBody: comment.Content,
|
||||||
|
ViewURL: s.documentURL(comment.DocumentID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -349,7 +416,7 @@ func (s *Service) enqueueNotificationEmail(ctx context.Context, notificationID,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if data.ActorName == "" {
|
if data.ActorName == "" {
|
||||||
data.ActorName = user.DisplayName
|
data.ActorName = "Someone"
|
||||||
}
|
}
|
||||||
subject, body, err := s.renderer.Render(kind, data)
|
subject, body, err := s.renderer.Render(kind, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -365,7 +432,25 @@ func (s *Service) documentURL(documentPath string) string {
|
|||||||
if s.publicOrigin == "" {
|
if s.publicOrigin == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return s.publicOrigin + "/" + documentPath
|
clean := strings.TrimPrefix(documentPath, "doc:")
|
||||||
|
clean = strings.TrimSuffix(strings.TrimPrefix(clean, "/"), ".md")
|
||||||
|
parts := strings.Split(clean, "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
parts[i] = url.PathEscape(part)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(s.publicOrigin, "/") + "/docs/" + strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) watcherCanRead(ctx context.Context, userID, documentID string) bool {
|
||||||
|
if s.canReadDocument == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
allowed, err := s.canReadDocument(ctx, userID, documentID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("authorize watcher", "user_id", userID, "document_id", documentID, "error", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) broadcastNotification(userID string) {
|
func (s *Service) broadcastNotification(userID string) {
|
||||||
|
|||||||
@@ -4,10 +4,27 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func NormalizeFolderPath(raw string) (string, error) {
|
||||||
|
raw = strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/"))
|
||||||
|
if raw == "" || raw == "/" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
clean := path.Clean(strings.Trim(raw, "/"))
|
||||||
|
if clean == "." {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if clean == ".." || strings.HasPrefix(clean, "../") {
|
||||||
|
return "", fmt.Errorf("folder path must remain inside the content root")
|
||||||
|
}
|
||||||
|
return clean, nil
|
||||||
|
}
|
||||||
|
|
||||||
func randomID(prefix string) string {
|
func randomID(prefix string) string {
|
||||||
buf := make([]byte, 12)
|
buf := make([]byte, 12)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
|||||||
@@ -39,15 +39,18 @@ type AuthConfig struct {
|
|||||||
|
|
||||||
type EmailConfig struct {
|
type EmailConfig struct {
|
||||||
// Provider selects the sender implementation. Valid values: "noop",
|
// Provider selects the sender implementation. Valid values: "noop",
|
||||||
// "smtp", "postmark". When empty, the loader infers from the other
|
// "smtp", "postmark", or "cloudflare". When empty, the loader infers
|
||||||
// fields: PostmarkToken wins, then SMTPHost, then NoOp.
|
// from credentials, then SMTPHost, then NoOp.
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
SMTPHost string `json:"smtpHost"`
|
SMTPHost string `json:"smtpHost"`
|
||||||
SMTPPort int `json:"smtpPort"`
|
SMTPPort int `json:"smtpPort"`
|
||||||
PostmarkToken string `json:"postmarkToken"`
|
PostmarkToken string `json:"postmarkToken"`
|
||||||
PostmarkAPIURL string `json:"postmarkApiUrl"`
|
PostmarkAPIURL string `json:"postmarkApiUrl"`
|
||||||
From string `json:"from"`
|
CloudflareAccountID string `json:"cloudflareAccountId"`
|
||||||
InstanceName string `json:"instanceName"`
|
CloudflareAPIToken string `json:"cloudflareApiToken"`
|
||||||
|
CloudflareAPIURL string `json:"cloudflareApiUrl"`
|
||||||
|
From string `json:"from"`
|
||||||
|
InstanceName string `json:"instanceName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolvedProvider returns the concrete provider choice after applying the
|
// ResolvedProvider returns the concrete provider choice after applying the
|
||||||
@@ -56,6 +59,9 @@ func (e EmailConfig) ResolvedProvider() string {
|
|||||||
if e.Provider != "" {
|
if e.Provider != "" {
|
||||||
return e.Provider
|
return e.Provider
|
||||||
}
|
}
|
||||||
|
if e.CloudflareAPIToken != "" || e.CloudflareAccountID != "" {
|
||||||
|
return "cloudflare"
|
||||||
|
}
|
||||||
if e.PostmarkToken != "" {
|
if e.PostmarkToken != "" {
|
||||||
return "postmark"
|
return "postmark"
|
||||||
}
|
}
|
||||||
@@ -112,6 +118,9 @@ func Load() (Config, error) {
|
|||||||
overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST")
|
overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST")
|
||||||
overrideString(&cfg.Email.PostmarkToken, "CAIRNQUIRE_EMAIL_POSTMARK_TOKEN")
|
overrideString(&cfg.Email.PostmarkToken, "CAIRNQUIRE_EMAIL_POSTMARK_TOKEN")
|
||||||
overrideString(&cfg.Email.PostmarkAPIURL, "CAIRNQUIRE_EMAIL_POSTMARK_API_URL")
|
overrideString(&cfg.Email.PostmarkAPIURL, "CAIRNQUIRE_EMAIL_POSTMARK_API_URL")
|
||||||
|
overrideString(&cfg.Email.CloudflareAccountID, "CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID")
|
||||||
|
overrideString(&cfg.Email.CloudflareAPIToken, "CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN")
|
||||||
|
overrideString(&cfg.Email.CloudflareAPIURL, "CAIRNQUIRE_EMAIL_CLOUDFLARE_API_URL")
|
||||||
overrideString(&cfg.Email.Provider, "CAIRNQUIRE_EMAIL_PROVIDER")
|
overrideString(&cfg.Email.Provider, "CAIRNQUIRE_EMAIL_PROVIDER")
|
||||||
overrideString(&cfg.Email.InstanceName, "CAIRNQUIRE_INSTANCE_NAME")
|
overrideString(&cfg.Email.InstanceName, "CAIRNQUIRE_INSTANCE_NAME")
|
||||||
if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" {
|
if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" {
|
||||||
|
|||||||
46
apps/server/internal/config/config_test.go
Normal file
46
apps/server/internal/config/config_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEmailConfigResolvedProvider(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg EmailConfig
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"explicit", EmailConfig{Provider: "noop", CloudflareAPIToken: "token"}, "noop"},
|
||||||
|
{"cloudflare", EmailConfig{CloudflareAccountID: "account"}, "cloudflare"},
|
||||||
|
{"postmark", EmailConfig{PostmarkToken: "token"}, "postmark"},
|
||||||
|
{"smtp", EmailConfig{SMTPHost: "localhost"}, "smtp"},
|
||||||
|
{"noop", EmailConfig{}, "noop"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := test.cfg.ResolvedProvider(); got != test.want {
|
||||||
|
t.Fatalf("ResolvedProvider() = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCloudflareEmailEnvironment(t *testing.T) {
|
||||||
|
t.Setenv("CAIRNQUIRE_EMAIL_PROVIDER", "cloudflare")
|
||||||
|
t.Setenv("CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID", "account")
|
||||||
|
t.Setenv("CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN", "token")
|
||||||
|
t.Setenv("CAIRNQUIRE_EMAIL_CLOUDFLARE_API_URL", "https://api.example.test")
|
||||||
|
t.Setenv("CAIRNQUIRE_EMAIL_FROM", "notifications@example.com")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Email.ResolvedProvider() != "cloudflare" {
|
||||||
|
t.Fatalf("provider = %q", cfg.Email.ResolvedProvider())
|
||||||
|
}
|
||||||
|
if cfg.Email.CloudflareAccountID != "account" || cfg.Email.CloudflareAPIToken != "token" {
|
||||||
|
t.Fatalf("cloudflare config = %#v", cfg.Email)
|
||||||
|
}
|
||||||
|
if cfg.Email.CloudflareAPIURL != "https://api.example.test" || cfg.Email.From != "notifications@example.com" {
|
||||||
|
t.Fatalf("cloudflare endpoint/from = %#v", cfg.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_watchers_user_folder_unique;
|
||||||
|
DROP INDEX IF EXISTS idx_watchers_user_document_unique;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
DELETE FROM watchers
|
||||||
|
WHERE rowid NOT IN (
|
||||||
|
SELECT MIN(rowid)
|
||||||
|
FROM watchers
|
||||||
|
GROUP BY user_id, document_id, folder_path
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_watchers_user_document_unique
|
||||||
|
ON watchers(user_id, document_id)
|
||||||
|
WHERE document_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_watchers_user_folder_unique
|
||||||
|
ON watchers(user_id, folder_path)
|
||||||
|
WHERE folder_path IS NOT NULL;
|
||||||
160
apps/server/internal/email/cloudflare.go
Normal file
160
apps/server/internal/email/cloudflare.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultCloudflareAPIURL = "https://api.cloudflare.com/client/v4"
|
||||||
|
cloudflareTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// CloudflareSender sends transactional email through Cloudflare Email
|
||||||
|
// Service's REST API. It is intended for deployments outside Workers.
|
||||||
|
type CloudflareSender struct {
|
||||||
|
accountID string
|
||||||
|
apiToken string
|
||||||
|
apiURL string
|
||||||
|
from string
|
||||||
|
client *http.Client
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCloudflareSender(accountID, apiToken, from, apiURL string, logger *slog.Logger) *CloudflareSender {
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = defaultCloudflareAPIURL
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &CloudflareSender{
|
||||||
|
accountID: accountID,
|
||||||
|
apiToken: apiToken,
|
||||||
|
apiURL: strings.TrimRight(apiURL, "/"),
|
||||||
|
from: from,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: cloudflareTimeout,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cloudflareRequest struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To []string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cloudflareResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Errors []struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"errors"`
|
||||||
|
Result *struct {
|
||||||
|
Delivered []string `json:"delivered"`
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
PermanentBounces []string `json:"permanent_bounces"`
|
||||||
|
Queued []string `json:"queued"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CloudflareSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
|
if c.accountID == "" {
|
||||||
|
return &PermanentError{Cause: fmt.Errorf("cloudflare account id not configured")}
|
||||||
|
}
|
||||||
|
if c.apiToken == "" {
|
||||||
|
return &PermanentError{Cause: fmt.Errorf("cloudflare API token not configured")}
|
||||||
|
}
|
||||||
|
if c.from == "" {
|
||||||
|
return &PermanentError{Cause: fmt.Errorf("cloudflare from address not configured")}
|
||||||
|
}
|
||||||
|
if len(to) == 0 {
|
||||||
|
return &PermanentError{Cause: fmt.Errorf("cloudflare send requires at least one recipient")}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(cloudflareRequest{
|
||||||
|
From: c.from,
|
||||||
|
To: to,
|
||||||
|
Subject: subject,
|
||||||
|
Text: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal cloudflare email request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/accounts/%s/email/sending/send", c.apiURL, url.PathEscape(c.accountID))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build cloudflare email request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return &TemporaryError{Cause: fmt.Errorf("cloudflare email request: %w", err)}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<14))
|
||||||
|
if readErr != nil {
|
||||||
|
return &TemporaryError{Cause: fmt.Errorf("read cloudflare email response: %w", readErr)}
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError {
|
||||||
|
return &TemporaryError{Cause: fmt.Errorf("cloudflare email server error: status=%d body=%s", resp.StatusCode, truncate(string(responseBody)))}
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &PermanentError{Cause: fmt.Errorf("cloudflare email rejected: status=%d body=%s", resp.StatusCode, truncate(string(responseBody)))}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed cloudflareResponse
|
||||||
|
if err := json.Unmarshal(responseBody, &parsed); err != nil {
|
||||||
|
return fmt.Errorf("decode cloudflare email response: %w", err)
|
||||||
|
}
|
||||||
|
if !parsed.Success {
|
||||||
|
return &PermanentError{Cause: fmt.Errorf("cloudflare email rejected: %s", cloudflareErrors(parsed.Errors))}
|
||||||
|
}
|
||||||
|
if parsed.Result == nil {
|
||||||
|
return fmt.Errorf("cloudflare email response missing result")
|
||||||
|
}
|
||||||
|
if len(parsed.Result.PermanentBounces) > 0 {
|
||||||
|
return &PermanentError{Cause: fmt.Errorf("cloudflare email permanently bounced for %d recipient(s)", len(parsed.Result.PermanentBounces))}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("cloudflare email accepted",
|
||||||
|
"message_id", parsed.Result.MessageID,
|
||||||
|
"delivered", len(parsed.Result.Delivered),
|
||||||
|
"queued", len(parsed.Result.Queued),
|
||||||
|
"subject", subject,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloudflareErrors(errors []struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}) string {
|
||||||
|
if len(errors) == 0 {
|
||||||
|
return "unknown API error"
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(errors))
|
||||||
|
for _, apiErr := range errors {
|
||||||
|
parts = append(parts, fmt.Sprintf("code=%d: %s", apiErr.Code, apiErr.Message))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
128
apps/server/internal/email/cloudflare_test.go
Normal file
128
apps/server/internal/email/cloudflare_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCloudflareSenderSend(t *testing.T) {
|
||||||
|
var got cloudflareRequest
|
||||||
|
var gotAuthorization string
|
||||||
|
var gotPath string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuthorization = r.Header.Get("Authorization")
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(body, &got)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"success":true,"errors":[],"messages":[],"result":{"delivered":["alice@example.com"],"message_id":"message-1","permanent_bounces":[],"queued":[]}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
sender := NewCloudflareSender("account-1", "token-1", "notifications@example.com", server.URL, testLogger(t))
|
||||||
|
if err := sender.Send(context.Background(), []string{"alice@example.com"}, "Hello", "World"); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
if gotAuthorization != "Bearer token-1" {
|
||||||
|
t.Errorf("authorization = %q", gotAuthorization)
|
||||||
|
}
|
||||||
|
if gotPath != "/accounts/account-1/email/sending/send" {
|
||||||
|
t.Errorf("path = %q", gotPath)
|
||||||
|
}
|
||||||
|
if got.From != "notifications@example.com" || got.Subject != "Hello" || got.Text != "World" {
|
||||||
|
t.Errorf("request = %#v", got)
|
||||||
|
}
|
||||||
|
if len(got.To) != 1 || got.To[0] != "alice@example.com" {
|
||||||
|
t.Errorf("to = %#v", got.To)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloudflareSenderRetryableResponses(t *testing.T) {
|
||||||
|
for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} {
|
||||||
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(`{"success":false}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
sender := NewCloudflareSender("account", "token", "from@example.com", server.URL, testLogger(t))
|
||||||
|
err := sender.Send(context.Background(), []string{"to@example.com"}, "subject", "body")
|
||||||
|
var temporary *TemporaryError
|
||||||
|
if !errors.As(err, &temporary) {
|
||||||
|
t.Fatalf("error = %T %v, want TemporaryError", err, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloudflareSenderPermanentRejection(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"success":false,"errors":[{"code":1000,"message":"Sender domain not verified"}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
sender := NewCloudflareSender("account", "token", "from@example.com", server.URL, testLogger(t))
|
||||||
|
err := sender.Send(context.Background(), []string{"to@example.com"}, "subject", "body")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "400") {
|
||||||
|
t.Fatalf("error = %v, want permanent 400 rejection", err)
|
||||||
|
}
|
||||||
|
var temporary *TemporaryError
|
||||||
|
if errors.As(err, &temporary) {
|
||||||
|
t.Fatalf("error = %T, should not be retryable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloudflareSenderAPIErrorAndBounce(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"api error", `{"success":false,"errors":[{"code":1000,"message":"Sender domain not verified"}],"result":null}`, "Sender domain not verified"},
|
||||||
|
{"bounce", `{"success":true,"errors":[],"result":{"delivered":[],"message_id":"message-1","permanent_bounces":["to@example.com"],"queued":[]}}`, "permanently bounced"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(test.body))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
sender := NewCloudflareSender("account", "token", "from@example.com", server.URL, testLogger(t))
|
||||||
|
err := sender.Send(context.Background(), []string{"to@example.com"}, "subject", "body")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("error = %v, want substring %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloudflareSenderValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
accountID string
|
||||||
|
token string
|
||||||
|
from string
|
||||||
|
to []string
|
||||||
|
}{
|
||||||
|
{"account", "", "token", "from@example.com", []string{"to@example.com"}},
|
||||||
|
{"token", "account", "", "from@example.com", []string{"to@example.com"}},
|
||||||
|
{"from", "account", "token", "", []string{"to@example.com"}},
|
||||||
|
{"recipient", "account", "token", "from@example.com", nil},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
sender := NewCloudflareSender(test.accountID, test.token, test.from, "", testLogger(t))
|
||||||
|
if err := sender.Send(context.Background(), test.to, "subject", "body"); err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,16 +36,21 @@ func NewPostmarkSender(serverToken, from, apiURL string, logger *slog.Logger) *P
|
|||||||
serverToken: serverToken,
|
serverToken: serverToken,
|
||||||
apiURL: strings.TrimRight(apiURL, "/"),
|
apiURL: strings.TrimRight(apiURL, "/"),
|
||||||
from: from,
|
from: from,
|
||||||
client: &http.Client{Timeout: postmarkTimeout},
|
client: &http.Client{
|
||||||
logger: logger,
|
Timeout: postmarkTimeout,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type postmarkRequest struct {
|
type postmarkRequest struct {
|
||||||
From string `json:"From"`
|
From string `json:"From"`
|
||||||
To string `json:"To"`
|
To string `json:"To"`
|
||||||
Subject string `json:"Subject"`
|
Subject string `json:"Subject"`
|
||||||
TextBody string `json:"TextBody"`
|
TextBody string `json:"TextBody"`
|
||||||
Headers []postmarkHeader `json:"Headers,omitempty"`
|
Headers []postmarkHeader `json:"Headers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,13 +71,13 @@ type postmarkResponse struct {
|
|||||||
// send if they need different reply-to addresses.
|
// send if they need different reply-to addresses.
|
||||||
func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body string) error {
|
func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
if p.serverToken == "" {
|
if p.serverToken == "" {
|
||||||
return fmt.Errorf("postmark server token not configured")
|
return &PermanentError{Cause: fmt.Errorf("postmark server token not configured")}
|
||||||
}
|
}
|
||||||
if p.from == "" {
|
if p.from == "" {
|
||||||
return fmt.Errorf("postmark from address not configured")
|
return &PermanentError{Cause: fmt.Errorf("postmark from address not configured")}
|
||||||
}
|
}
|
||||||
if len(to) == 0 {
|
if len(to) == 0 {
|
||||||
return fmt.Errorf("postmark send requires at least one recipient")
|
return &PermanentError{Cause: fmt.Errorf("postmark send requires at least one recipient")}
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := postmarkRequest{
|
payload := postmarkRequest{
|
||||||
@@ -96,7 +101,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st
|
|||||||
|
|
||||||
resp, err := p.client.Do(req)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("postmark request: %w", err)
|
return &TemporaryError{Cause: fmt.Errorf("postmark request: %w", err)}
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
@@ -107,7 +112,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st
|
|||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))
|
return &PermanentError{Cause: fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))}
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed postmarkResponse
|
var parsed postmarkResponse
|
||||||
@@ -117,7 +122,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st
|
|||||||
if parsed.ErrorCode != 0 {
|
if parsed.ErrorCode != 0 {
|
||||||
// Postmark distinguishes "inactive recipient" (406) and other errors. Treat
|
// Postmark distinguishes "inactive recipient" (406) and other errors. Treat
|
||||||
// 406 as non-retryable; others bubble up as generic failures.
|
// 406 as non-retryable; others bubble up as generic failures.
|
||||||
return fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message)
|
return &PermanentError{Cause: fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message)}
|
||||||
}
|
}
|
||||||
|
|
||||||
p.logger.Info("postmark email sent", "to", parsed.To, "subject", subject)
|
p.logger.Info("postmark email sent", "to", parsed.To, "subject", subject)
|
||||||
@@ -139,3 +144,11 @@ type TemporaryError struct {
|
|||||||
|
|
||||||
func (t *TemporaryError) Error() string { return t.Cause.Error() }
|
func (t *TemporaryError) Error() string { return t.Cause.Error() }
|
||||||
func (t *TemporaryError) Unwrap() error { return t.Cause }
|
func (t *TemporaryError) Unwrap() error { return t.Cause }
|
||||||
|
|
||||||
|
// PermanentError marks a provider rejection that cannot succeed on retry.
|
||||||
|
type PermanentError struct {
|
||||||
|
Cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PermanentError) Error() string { return p.Cause.Error() }
|
||||||
|
func (p *PermanentError) Unwrap() error { return p.Cause }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package email
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
@@ -211,17 +212,28 @@ func (q *Queue) deliver(ctx context.Context, row QueueRow) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Failure: retry or give up.
|
// Failure: retry or give up.
|
||||||
if row.Attempts >= row.MaxAttempts {
|
var permanent *PermanentError
|
||||||
|
if errors.As(err, &permanent) {
|
||||||
|
if _, ferr := q.db.ExecContext(ctx, `
|
||||||
|
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
|
||||||
|
`, truncateErr(err.Error()), row.ID); ferr != nil {
|
||||||
|
return fmt.Errorf("mark permanent failure: %w", ferr)
|
||||||
|
}
|
||||||
|
q.logger.Warn("email permanently rejected", "id", row.ID, "attempts", row.Attempts+1, "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currentAttempt := row.Attempts + 1
|
||||||
|
if currentAttempt >= row.MaxAttempts {
|
||||||
if _, ferr := q.db.ExecContext(ctx, `
|
if _, ferr := q.db.ExecContext(ctx, `
|
||||||
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
|
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
|
||||||
`, truncateErr(err.Error()), row.ID); ferr != nil {
|
`, truncateErr(err.Error()), row.ID); ferr != nil {
|
||||||
return fmt.Errorf("mark failed: %w", ferr)
|
return fmt.Errorf("mark failed: %w", ferr)
|
||||||
}
|
}
|
||||||
q.logger.Warn("email permanently failed", "id", row.ID, "attempts", row.Attempts, "error", err)
|
q.logger.Warn("email permanently failed", "id", row.ID, "attempts", currentAttempt, "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
backoff := q.backoff(row.Attempts)
|
backoff := q.backoff(currentAttempt)
|
||||||
next := time.Now().UTC().Add(backoff)
|
next := time.Now().UTC().Add(backoff)
|
||||||
if _, err := q.db.ExecContext(ctx, `
|
if _, err := q.db.ExecContext(ctx, `
|
||||||
UPDATE email_queue SET status = 'pending', next_attempt_at = ?, last_error = ? WHERE id = ?
|
UPDATE email_queue SET status = 'pending', next_attempt_at = ?, last_error = ? WHERE id = ?
|
||||||
@@ -229,9 +241,9 @@ func (q *Queue) deliver(ctx context.Context, row QueueRow) error {
|
|||||||
return fmt.Errorf("schedule retry: %w", err)
|
return fmt.Errorf("schedule retry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TemporaryError (Postmark 5xx) is retried with the same backoff as
|
// Temporary provider failures and unclassified sender failures are retried;
|
||||||
// other errors; the max-attempts cap still bounds total work.
|
// PermanentError is handled above without wasting additional attempts.
|
||||||
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", row.Attempts, "backoff", backoff, "error", err)
|
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", currentAttempt, "backoff", backoff, "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -152,9 +153,9 @@ func TestQueue_RetryThenSucceed(t *testing.T) {
|
|||||||
go q.Run(ctx)
|
go q.Run(ctx)
|
||||||
|
|
||||||
if err := waitFor(ctx, func() bool {
|
if err := waitFor(ctx, func() bool {
|
||||||
return sender.successCount >= 1
|
return sender.successCount.Load() >= 1
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount, sender.attempts, err)
|
t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount.Load(), sender.attempts.Load(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the row is marked sent.
|
// Verify the row is marked sent.
|
||||||
@@ -196,6 +197,32 @@ func TestQueue_PermanentFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueue_ProviderRejectionFailsImmediately(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
sender := &recordingSender{err: &PermanentError{Cause: errors.New("sender domain not verified")}}
|
||||||
|
q := NewQueue(db, sender, testLogger(t))
|
||||||
|
|
||||||
|
id, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Fail", "Body")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
rows, err := q.claimBatch(context.Background(), 1)
|
||||||
|
if err != nil || len(rows) != 1 {
|
||||||
|
t.Fatalf("claimBatch = %#v, %v", rows, err)
|
||||||
|
}
|
||||||
|
if err := q.deliver(context.Background(), rows[0]); err != nil {
|
||||||
|
t.Fatalf("deliver: %v", err)
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
var attempts int
|
||||||
|
if err := db.QueryRow(`SELECT status, attempts FROM email_queue WHERE id = ?`, id).Scan(&status, &attempts); err != nil {
|
||||||
|
t.Fatalf("query row: %v", err)
|
||||||
|
}
|
||||||
|
if status != "failed" || attempts != 1 {
|
||||||
|
t.Fatalf("status=%q attempts=%d, want failed after one attempt", status, attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestQueue_NilSenderLeavesPending(t *testing.T) {
|
func TestQueue_NilSenderLeavesPending(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
q := NewQueue(db, nil, testLogger(t))
|
q := NewQueue(db, nil, testLogger(t))
|
||||||
@@ -218,17 +245,17 @@ func TestQueue_NilSenderLeavesPending(t *testing.T) {
|
|||||||
|
|
||||||
// flakySender fails the first N attempts then succeeds.
|
// flakySender fails the first N attempts then succeeds.
|
||||||
type flakySender struct {
|
type flakySender struct {
|
||||||
failTimes int
|
failTimes int32
|
||||||
attempts int
|
attempts atomic.Int32
|
||||||
successCount int
|
successCount atomic.Int32
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error {
|
func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
f.attempts++
|
attempt := f.attempts.Add(1)
|
||||||
if f.attempts <= f.failTimes {
|
if attempt <= f.failTimes {
|
||||||
return errors.New("transient failure")
|
return errors.New("transient failure")
|
||||||
}
|
}
|
||||||
f.successCount++
|
f.successCount.Add(1)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ import (
|
|||||||
// TemplateData carries the context required to render any notification email.
|
// TemplateData carries the context required to render any notification email.
|
||||||
// Fields are optional per template; callers should populate what they have.
|
// Fields are optional per template; callers should populate what they have.
|
||||||
type TemplateData struct {
|
type TemplateData struct {
|
||||||
ActorName string
|
ActorName string
|
||||||
ActorEmail string
|
ActorEmail string
|
||||||
DocumentPath string
|
DocumentPath string
|
||||||
DocumentTitle string
|
DocumentTitle string
|
||||||
CommentBody string
|
CommentBody string
|
||||||
MentionText string
|
MentionText string
|
||||||
ConflictDesc string
|
ConflictDesc string
|
||||||
ViewURL string
|
ViewURL string
|
||||||
UnsubURL string
|
UnsubURL string
|
||||||
InstanceName string
|
InstanceName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render returns a plain-text subject and body for the given notification
|
// Render returns a plain-text subject and body for the given notification
|
||||||
@@ -101,7 +101,7 @@ func renderComment(data TemplateData) (string, string, error) {
|
|||||||
} else {
|
} else {
|
||||||
b.WriteString("> (no body)\n")
|
b.WriteString("> (no body)\n")
|
||||||
}
|
}
|
||||||
b.WriteString("\nReply to this email to respond.\n")
|
b.WriteString("\nOpen the document to reply.\n")
|
||||||
b.WriteString(footer(data))
|
b.WriteString(footer(data))
|
||||||
return subject, b.String(), nil
|
return subject, b.String(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import (
|
|||||||
|
|
||||||
func TestRender_FileChanged(t *testing.T) {
|
func TestRender_FileChanged(t *testing.T) {
|
||||||
subject, body, err := Render("file_changed", TemplateData{
|
subject, body, err := Render("file_changed", TemplateData{
|
||||||
ActorName: "Alice",
|
ActorName: "Alice",
|
||||||
DocumentPath: "docs/api.md",
|
DocumentPath: "docs/api.md",
|
||||||
DocumentTitle: "API Reference",
|
DocumentTitle: "API Reference",
|
||||||
ViewURL: "https://example.com/docs/api.md",
|
ViewURL: "https://example.com/docs/api.md",
|
||||||
UnsubURL: "https://example.com/unsub/123",
|
UnsubURL: "https://example.com/unsub/123",
|
||||||
InstanceName: "Cairnquire",
|
InstanceName: "Cairnquire",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Render: %v", err)
|
t.Fatalf("Render: %v", err)
|
||||||
@@ -39,10 +39,10 @@ func TestRender_FileChanged(t *testing.T) {
|
|||||||
|
|
||||||
func TestRender_Comment(t *testing.T) {
|
func TestRender_Comment(t *testing.T) {
|
||||||
subject, body, err := Render("comment", TemplateData{
|
subject, body, err := Render("comment", TemplateData{
|
||||||
ActorName: "Bob",
|
ActorName: "Bob",
|
||||||
DocumentTitle: "Getting Started",
|
DocumentTitle: "Getting Started",
|
||||||
CommentBody: "This step is unclear.\nCan we add an example?",
|
CommentBody: "This step is unclear.\nCan we add an example?",
|
||||||
ViewURL: "https://example.com/docs/getting-started",
|
ViewURL: "https://example.com/docs/getting-started",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Render: %v", err)
|
t.Fatalf("Render: %v", err)
|
||||||
@@ -56,16 +56,16 @@ func TestRender_Comment(t *testing.T) {
|
|||||||
if !strings.Contains(body, "> Can we add an example?") {
|
if !strings.Contains(body, "> Can we add an example?") {
|
||||||
t.Errorf("body should quote second line: %q", body)
|
t.Errorf("body should quote second line: %q", body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, "Reply to this email") {
|
if !strings.Contains(body, "Open the document to reply") {
|
||||||
t.Errorf("body should mention reply-to-respond: %q", body)
|
t.Errorf("body should link users to the supported reply flow: %q", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRender_Mention(t *testing.T) {
|
func TestRender_Mention(t *testing.T) {
|
||||||
subject, body, err := Render("mention", TemplateData{
|
subject, body, err := Render("mention", TemplateData{
|
||||||
ActorName: "Carol",
|
ActorName: "Carol",
|
||||||
DocumentTitle: "Spec",
|
DocumentTitle: "Spec",
|
||||||
MentionText: "@dan please review",
|
MentionText: "@dan please review",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Render: %v", err)
|
t.Fatalf("Render: %v", err)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/collaboration"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/config"
|
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||||
@@ -99,6 +100,13 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncRepo := sync.NewRepository(db.SQL())
|
syncRepo := sync.NewRepository(db.SQL())
|
||||||
|
hub := realtime.NewHub(logger)
|
||||||
|
collabService := collaboration.NewServiceWithOptions(
|
||||||
|
collaboration.NewRepository(db.SQL()),
|
||||||
|
hub,
|
||||||
|
logger,
|
||||||
|
collaboration.Options{UserLookup: auth.NewRepository(db.SQL()), PublicOrigin: "http://localhost:8080"},
|
||||||
|
)
|
||||||
handler, err := New(Dependencies{
|
handler, err := New(Dependencies{
|
||||||
Config: config.Config{
|
Config: config.Config{
|
||||||
Content: config.ContentConfig{
|
Content: config.ContentConfig{
|
||||||
@@ -108,14 +116,15 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo
|
|||||||
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
|
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
|
||||||
DevMode: devMode,
|
DevMode: devMode,
|
||||||
},
|
},
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
Documents: docService,
|
Documents: docService,
|
||||||
Repository: docRepo,
|
Repository: docRepo,
|
||||||
ContentStore: contentStore,
|
ContentStore: contentStore,
|
||||||
Hub: realtime.NewHub(logger),
|
Hub: hub,
|
||||||
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
|
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
|
||||||
SyncRepo: syncRepo,
|
SyncRepo: syncRepo,
|
||||||
Auth: authService,
|
Auth: authService,
|
||||||
|
Collaboration: collabService,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create http server: %v", err)
|
t.Fatalf("create http server: %v", err)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package httpserver
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/collaboration"
|
"github.com/tim/cairnquire/apps/server/internal/collaboration"
|
||||||
@@ -32,11 +34,12 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
anchorHash := r.URL.Query().Get("anchor_hash")
|
anchorHash := r.URL.Query().Get("anchor_hash")
|
||||||
|
includeResolved := r.URL.Query().Get("include_resolved") == "true"
|
||||||
var comments []collaboration.Comment
|
var comments []collaboration.Comment
|
||||||
if anchorHash != "" {
|
if anchorHash != "" {
|
||||||
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash)
|
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash, includeResolved)
|
||||||
} else {
|
} else {
|
||||||
comments, err = s.collaboration.ListComments(r.Context(), documentID)
|
comments, err = s.collaboration.ListComments(r.Context(), documentID, includeResolved)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("list comments", "error", err)
|
s.logger.Warn("list comments", "error", err)
|
||||||
@@ -128,8 +131,13 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
unreadOnly := r.URL.Query().Get("unread") == "true"
|
unreadOnly := r.URL.Query().Get("unread") == "true"
|
||||||
limit := 50
|
limit := 50
|
||||||
if r.URL.Query().Get("limit") != "" {
|
if rawLimit := r.URL.Query().Get("limit"); rawLimit != "" {
|
||||||
// Parse limit if needed, but for now just use default
|
parsed, err := strconv.Atoi(rawLimit)
|
||||||
|
if err != nil || parsed < 1 || parsed > 100 {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit = parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
notifications, err := s.collaboration.ListNotifications(r.Context(), principal, unreadOnly, limit)
|
notifications, err := s.collaboration.ListNotifications(r.Context(), principal, unreadOnly, limit)
|
||||||
@@ -204,6 +212,21 @@ func (s *Server) handleWatchDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.DocumentID = strings.TrimSpace(req.DocumentID)
|
||||||
|
document, err := s.repository.GetDocumentByID(r.Context(), req.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canRead, err := s.canReadDocumentRecord(r, *document)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canRead {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.collaboration.WatchDocument(r.Context(), principal, req.DocumentID); err != nil {
|
if err := s.collaboration.WatchDocument(r.Context(), principal, req.DocumentID); err != nil {
|
||||||
s.logger.Warn("watch document", "error", err)
|
s.logger.Warn("watch document", "error", err)
|
||||||
@@ -227,6 +250,7 @@ func (s *Server) handleUnwatchDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.DocumentID = strings.TrimSpace(req.DocumentID)
|
||||||
|
|
||||||
if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil {
|
if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil {
|
||||||
s.logger.Warn("unwatch document", "error", err)
|
s.logger.Warn("unwatch document", "error", err)
|
||||||
@@ -250,8 +274,17 @@ func (s *Server) handleWatchFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
normalized, err := collaboration.NormalizeFolderPath(req.FolderPath)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.canReadFolder(r, normalized) {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "folder not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.collaboration.WatchFolder(r.Context(), principal, req.FolderPath); err != nil {
|
if err := s.collaboration.WatchFolder(r.Context(), principal, normalized); err != nil {
|
||||||
s.logger.Warn("watch folder", "error", err)
|
s.logger.Warn("watch folder", "error", err)
|
||||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -273,8 +306,13 @@ func (s *Server) handleUnwatchFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
normalized, err := collaboration.NormalizeFolderPath(req.FolderPath)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.collaboration.UnwatchFolder(r.Context(), principal, req.FolderPath); err != nil {
|
if err := s.collaboration.UnwatchFolder(r.Context(), principal, normalized); err != nil {
|
||||||
s.logger.Warn("unwatch folder", "error", err)
|
s.logger.Warn("unwatch folder", "error", err)
|
||||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -290,6 +328,20 @@ func (s *Server) handleIsWatching(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
documentID := r.URL.Query().Get("document_id")
|
documentID := r.URL.Query().Get("document_id")
|
||||||
|
document, err := s.repository.GetDocumentByID(r.Context(), documentID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canRead, err := s.canReadDocumentRecord(r, *document)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canRead {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
watching, err := s.collaboration.IsWatching(r.Context(), principal, documentID)
|
watching, err := s.collaboration.IsWatching(r.Context(), principal, documentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("is watching", "error", err)
|
s.logger.Warn("is watching", "error", err)
|
||||||
|
|||||||
141
apps/server/internal/httpserver/collab_handlers_test.go
Normal file
141
apps/server/internal/httpserver/collab_handlers_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/collaboration"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loginTestUser(t *testing.T, server *apiTestServer) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
login := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||||
|
"email": "editor@example.com", "password": "very secure password",
|
||||||
|
}))
|
||||||
|
if login.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login response = %d %q", login.Code, login.Body.String())
|
||||||
|
}
|
||||||
|
return cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticatedJSONRequest(t *testing.T, method, target string, body any, session *http.Cookie) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
request := jsonRequest(method, target, body)
|
||||||
|
request.AddCookie(session)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentLoadsCollaborationControlsAndWatchFlow(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
session := loginTestUser(t, server)
|
||||||
|
|
||||||
|
pageRequest := httptest.NewRequest(http.MethodGet, "/docs/hello", nil)
|
||||||
|
pageRequest.AddCookie(session)
|
||||||
|
page := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(page, pageRequest)
|
||||||
|
if page.Code != http.StatusOK {
|
||||||
|
t.Fatalf("document response = %d %q", page.Code, page.Body.String())
|
||||||
|
}
|
||||||
|
for _, expected := range [][]byte{
|
||||||
|
[]byte(`/static/notification-bell.js`),
|
||||||
|
[]byte(`data-document-id="doc:hello"`),
|
||||||
|
[]byte(`data-document-watch`),
|
||||||
|
[]byte(`data-comments-history`),
|
||||||
|
} {
|
||||||
|
if !bytes.Contains(page.Body.Bytes(), expected) {
|
||||||
|
t.Errorf("document page missing %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusRequest := httptest.NewRequest(http.MethodGet, "/api/watch/status?document_id=doc%3Ahello", nil)
|
||||||
|
statusRequest.AddCookie(session)
|
||||||
|
status := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(status, statusRequest)
|
||||||
|
if status.Code != http.StatusOK || !bytes.Contains(status.Body.Bytes(), []byte(`"watching":false`)) {
|
||||||
|
t.Fatalf("initial watch status = %d %q", status.Code, status.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
watch := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(watch, authenticatedJSONRequest(t, http.MethodPost, "/api/watch/document", map[string]string{"documentId": "doc:hello"}, session))
|
||||||
|
if watch.Code != http.StatusOK {
|
||||||
|
t.Fatalf("watch response = %d %q", watch.Code, watch.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
status = httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(status, statusRequest.Clone(statusRequest.Context()))
|
||||||
|
if status.Code != http.StatusOK || !bytes.Contains(status.Body.Bytes(), []byte(`"watching":true`)) {
|
||||||
|
t.Fatalf("watched status = %d %q", status.Code, status.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
unwatch := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(unwatch, authenticatedJSONRequest(t, http.MethodDelete, "/api/watch/document", map[string]string{"documentId": "doc:hello"}, session))
|
||||||
|
if unwatch.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unwatch response = %d %q", unwatch.Code, unwatch.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentReplyResolveAndHistoryHTTPFlow(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
session := loginTestUser(t, server)
|
||||||
|
anchor := "section-anchor"
|
||||||
|
|
||||||
|
create := func(content string, parentID *string) collaboration.Comment {
|
||||||
|
t.Helper()
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(response, authenticatedJSONRequest(t, http.MethodPost, "/api/comments", collaboration.CreateCommentInput{
|
||||||
|
DocumentID: "doc:hello", VersionHash: "hash", ParentID: parentID, Content: content, AnchorHash: &anchor,
|
||||||
|
}, session))
|
||||||
|
if response.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create comment response = %d %q", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
var comment collaboration.Comment
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&comment); err != nil {
|
||||||
|
t.Fatalf("decode comment: %v", err)
|
||||||
|
}
|
||||||
|
return comment
|
||||||
|
}
|
||||||
|
|
||||||
|
root := create("Root", nil)
|
||||||
|
reply := create("Reply", &root.ID)
|
||||||
|
_ = create("Grandchild", &reply.ID)
|
||||||
|
|
||||||
|
resolve := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(resolve, authenticatedJSONRequest(t, http.MethodPost, "/api/comments/"+root.ID+"/resolve", map[string]any{}, session))
|
||||||
|
if resolve.Code != http.StatusOK {
|
||||||
|
t.Fatalf("resolve response = %d %q", resolve.Code, resolve.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
list := func(includeResolved bool) []collaboration.Comment {
|
||||||
|
t.Helper()
|
||||||
|
target := "/api/comments?document_id=doc%3Ahello&anchor_hash=" + anchor
|
||||||
|
if includeResolved {
|
||||||
|
target += "&include_resolved=true"
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodGet, target, nil)
|
||||||
|
request.AddCookie(session)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list comments response = %d %q", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Comments []collaboration.Comment `json:"comments"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode comments: %v", err)
|
||||||
|
}
|
||||||
|
return body.Comments
|
||||||
|
}
|
||||||
|
|
||||||
|
if active := list(false); len(active) != 0 {
|
||||||
|
t.Fatalf("active comments = %d, want 0", len(active))
|
||||||
|
}
|
||||||
|
if history := list(true); len(history) != 3 {
|
||||||
|
t.Fatalf("history comments = %d, want 3", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ type indexData struct {
|
|||||||
|
|
||||||
type documentData struct {
|
type documentData struct {
|
||||||
Browser browserData
|
Browser browserData
|
||||||
|
ID string
|
||||||
Title string
|
Title string
|
||||||
Path string
|
Path string
|
||||||
Tags []string
|
Tags []string
|
||||||
@@ -375,6 +376,7 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
|
|||||||
BodyTemplate: "document_content",
|
BodyTemplate: "document_content",
|
||||||
Data: documentData{
|
Data: documentData{
|
||||||
Browser: browser,
|
Browser: browser,
|
||||||
|
ID: record.ID,
|
||||||
Title: page.Title,
|
Title: page.Title,
|
||||||
Path: page.Path,
|
Path: page.Path,
|
||||||
Tags: page.Tags,
|
Tags: page.Tags,
|
||||||
|
|||||||
@@ -207,7 +207,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
|||||||
case strings.HasPrefix(path, "/api/content/"):
|
case strings.HasPrefix(path, "/api/content/"):
|
||||||
return auth.ScopeSyncRead, true
|
return auth.ScopeSyncRead, true
|
||||||
case strings.HasPrefix(path, "/api/comments"):
|
case strings.HasPrefix(path, "/api/comments"):
|
||||||
return auth.ScopeDocsRead, true
|
if method == http.MethodGet {
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
}
|
||||||
|
return auth.ScopeDocsWrite, true
|
||||||
case strings.HasPrefix(path, "/api/notifications"):
|
case strings.HasPrefix(path, "/api/notifications"):
|
||||||
return auth.ScopeDocsRead, true
|
return auth.ScopeDocsRead, true
|
||||||
case strings.HasPrefix(path, "/api/watch"):
|
case strings.HasPrefix(path, "/api/watch"):
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
|
const article = document.querySelector('[data-document-id]');
|
||||||
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash;
|
if (!article) return;
|
||||||
const canComment = document.querySelector('[data-document-path]')?.dataset.canComment === 'true';
|
|
||||||
const article = document.querySelector('[data-document-path]');
|
const documentId = article.dataset.documentId;
|
||||||
const workspaceShell = article?.closest('.workspace-shell--document');
|
const documentHash = article.dataset.documentHash;
|
||||||
|
const canComment = article.dataset.canComment === 'true';
|
||||||
|
const workspaceShell = article.closest('.workspace-shell--document');
|
||||||
const commentsAsideList = document.querySelector('[data-comments-aside-list]');
|
const commentsAsideList = document.querySelector('[data-comments-aside-list]');
|
||||||
const commentsAsideListMobile = document.querySelector('[data-comments-aside-list-mobile]');
|
const commentsAsideListMobile = document.querySelector('[data-comments-aside-list-mobile]');
|
||||||
const stripTrack = document.querySelector('[data-comments-strip-track]');
|
const stripTrack = document.querySelector('[data-comments-strip-track]');
|
||||||
const commentsAside = document.querySelector('[data-comments-aside]');
|
const commentsAside = document.querySelector('[data-comments-aside]');
|
||||||
const commentsSheetToggle = document.querySelector('[data-toggle-comments]');
|
const commentsSheetToggle = document.querySelector('[data-toggle-comments]');
|
||||||
const commentsSheetClose = document.querySelector('[data-close-comments]');
|
const commentsSheetClose = document.querySelector('[data-close-comments]');
|
||||||
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
|
|
||||||
const commentToggle = document.querySelector('[data-comment-toggle]');
|
const commentToggle = document.querySelector('[data-comment-toggle]');
|
||||||
const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null;
|
const watchButton = document.querySelector('[data-document-watch]');
|
||||||
|
const historyButtons = document.querySelectorAll('[data-comments-history]');
|
||||||
|
const commentVisibilityKey = `cairnquire:document-comments:${documentId}`;
|
||||||
|
const commentsByAnchor = new Map();
|
||||||
|
const elementsByAnchor = new Map();
|
||||||
|
let includeResolved = false;
|
||||||
|
let watching = false;
|
||||||
|
|
||||||
function readCommentVisibility() {
|
function readCommentVisibility() {
|
||||||
if (!commentVisibilityKey) return false;
|
|
||||||
try {
|
try {
|
||||||
return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
|
return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -25,29 +31,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!documentId) return;
|
|
||||||
|
|
||||||
function syncVisibility() {
|
|
||||||
const hidden = readCommentVisibility();
|
|
||||||
if (article) article.classList.toggle('comments-hidden', hidden);
|
|
||||||
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
|
|
||||||
}
|
|
||||||
|
|
||||||
syncVisibility();
|
|
||||||
|
|
||||||
function commentsHidden() {
|
function commentsHidden() {
|
||||||
return Boolean(workspaceShell?.classList.contains('comments-visible')) === false;
|
return !workspaceShell?.classList.contains('comments-visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCommentsHidden(hidden) {
|
function setCommentsHidden(hidden) {
|
||||||
if (article) article.classList.toggle('comments-hidden', hidden);
|
article.classList.toggle('comments-hidden', hidden);
|
||||||
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
|
workspaceShell?.classList.toggle('comments-visible', !hidden);
|
||||||
if (commentVisibilityKey) {
|
try {
|
||||||
try {
|
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
|
||||||
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
|
} catch (err) {
|
||||||
} catch (err) {
|
// Storage can be unavailable in private browsing contexts.
|
||||||
// Ignore persistence failures
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (commentToggle) {
|
if (commentToggle) {
|
||||||
commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true');
|
commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true');
|
||||||
@@ -60,173 +54,218 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commentToggle) {
|
setCommentsHidden(readCommentVisibility());
|
||||||
commentToggle.setAttribute('aria-pressed', commentsHidden() ? 'false' : 'true');
|
commentToggle?.addEventListener('click', () => setCommentsHidden(!commentsHidden()));
|
||||||
commentToggle.addEventListener('click', () => {
|
|
||||||
setCommentsHidden(!commentsHidden());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function hashParagraph(el) {
|
function hashParagraph(el) {
|
||||||
const text = (el.textContent || '').trim();
|
const text = (el.textContent || '').trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < text.length; i++) {
|
for (let i = 0; i < text.length; i++) {
|
||||||
const char = text.charCodeAt(i);
|
hash = ((hash << 5) - hash) + text.charCodeAt(i);
|
||||||
hash = ((hash << 5) - hash) + char;
|
hash &= hash;
|
||||||
hash = hash & hash;
|
|
||||||
}
|
}
|
||||||
return 'h' + Math.abs(hash).toString(36);
|
return 'h' + Math.abs(hash).toString(36);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addCommentButton(el, hash) {
|
function addCommentButton(el, anchorHash) {
|
||||||
const btn = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
btn.className = 'comment-anchor-btn';
|
button.className = 'comment-anchor-btn';
|
||||||
btn.type = 'button';
|
button.type = 'button';
|
||||||
btn.title = 'Add comment';
|
button.title = 'Add comment';
|
||||||
btn.setAttribute('aria-label', 'Add comment');
|
button.setAttribute('aria-label', 'Add comment');
|
||||||
btn.innerHTML = `
|
button.innerHTML = `
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
|
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
|
||||||
<path d="M12 8v4"></path>
|
<path d="M12 8v4"></path><path d="M10 10h4"></path>
|
||||||
<path d="M10 10h4"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span class="comment-anchor-count" aria-hidden="true" hidden></span>
|
<span class="comment-anchor-count" aria-hidden="true" hidden></span>
|
||||||
<span class="sr-only">Add comment</span>
|
<span class="sr-only">Add comment</span>`;
|
||||||
`;
|
button.addEventListener('click', () => createComment(anchorHash));
|
||||||
btn.addEventListener('click', () => promptComment(el, hash));
|
el.appendChild(button);
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCommentButtonState(el, count) {
|
function updateCommentButtonState(el, comments) {
|
||||||
const btn = el.querySelector('.comment-anchor-btn');
|
const button = el.querySelector('.comment-anchor-btn');
|
||||||
if (!btn) return;
|
const count = comments.filter(comment => !comment.resolvedAt).length;
|
||||||
const hasComments = count > 0;
|
if (button) {
|
||||||
btn.classList.toggle('comment-anchor-btn--has-comments', hasComments);
|
const hasComments = count > 0;
|
||||||
btn.setAttribute('aria-label', hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment');
|
button.classList.toggle('comment-anchor-btn--has-comments', hasComments);
|
||||||
btn.title = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
|
const label = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
|
||||||
const countNode = btn.querySelector('.comment-anchor-count');
|
button.setAttribute('aria-label', label);
|
||||||
if (countNode) {
|
button.title = label;
|
||||||
countNode.hidden = !hasComments;
|
const countNode = button.querySelector('.comment-anchor-count');
|
||||||
countNode.textContent = count > 99 ? '99+' : String(count);
|
if (countNode) {
|
||||||
|
countNode.hidden = !hasComments;
|
||||||
|
countNode.textContent = count > 99 ? '99+' : String(count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function promptComment(el, anchorHash) {
|
async function createComment(anchorHash, parentId) {
|
||||||
const text = window.prompt('Comment on this paragraph:');
|
const promptText = parentId ? 'Reply to this comment:' : 'Comment on this section:';
|
||||||
if (!text || !text.trim()) return;
|
const content = window.prompt(promptText);
|
||||||
|
if (!content || !content.trim()) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/comments', {
|
const response = await fetch('/api/comments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ documentId, versionHash: documentHash, content: text.trim(), anchorHash }),
|
body: JSON.stringify({
|
||||||
|
documentId,
|
||||||
|
versionHash: documentHash,
|
||||||
|
content: content.trim(),
|
||||||
|
anchorHash,
|
||||||
|
parentId: parentId || undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
await loadCommentsForAnchor(el, anchorHash);
|
await loadCommentsForAnchor(anchorHash);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.alert('Failed to post comment: ' + err.message);
|
window.alert('Failed to post comment: ' + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCommentsForAnchor(el, anchorHash) {
|
async function resolveThread(comment, anchorHash) {
|
||||||
|
if (!window.confirm('Resolve this comment thread?')) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/comments?document_id=${encodeURIComponent(documentId)}&anchor_hash=${encodeURIComponent(anchorHash)}`);
|
const response = await fetch(`/api/comments/${encodeURIComponent(comment.id)}/resolve`, { method: 'POST' });
|
||||||
if (!res.ok) return;
|
if (!response.ok) throw new Error(await response.text());
|
||||||
const data = await res.json();
|
await loadCommentsForAnchor(anchorHash);
|
||||||
renderComments(el, data.comments || []);
|
} catch (err) {
|
||||||
|
window.alert('Failed to resolve comment: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommentsForAnchor(anchorHash) {
|
||||||
|
const element = elementsByAnchor.get(anchorHash);
|
||||||
|
if (!element) return;
|
||||||
|
const params = new URLSearchParams({ document_id: documentId, anchor_hash: anchorHash });
|
||||||
|
if (includeResolved) params.set('include_resolved', 'true');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/comments?${params}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json();
|
||||||
|
const comments = data.comments || [];
|
||||||
|
commentsByAnchor.set(anchorHash, comments);
|
||||||
|
renderInlineComments(element, anchorHash, comments);
|
||||||
|
renderCommentsAside();
|
||||||
|
updateStripMarkers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('load comments', err);
|
console.error('load comments', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderComments(el, comments) {
|
function buildCommentForest(comments) {
|
||||||
const existing = el.querySelector('.comment-list');
|
const nodes = new Map(comments.map(comment => [comment.id, { comment, children: [] }]));
|
||||||
if (existing) existing.remove();
|
const roots = [];
|
||||||
|
comments.forEach(comment => {
|
||||||
updateCommentButtonState(el, comments.length);
|
const node = nodes.get(comment.id);
|
||||||
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
|
const parent = comment.parentId ? nodes.get(comment.parentId) : null;
|
||||||
|
if (parent) parent.children.push(node);
|
||||||
if (!comments.length) {
|
else roots.push(node);
|
||||||
renderCommentsAside();
|
|
||||||
updateStripMarkers();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = document.createElement('div');
|
|
||||||
list.className = 'comment-list';
|
|
||||||
comments.forEach(c => {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'comment-item';
|
|
||||||
item.innerHTML = `
|
|
||||||
<div class="comment-meta"><strong>${escapeHtml(c.authorName || 'Anonymous')}</strong><time>${formatDate(c.createdAt)}</time></div>
|
|
||||||
<div class="comment-body">${escapeHtml(c.content)}</div>
|
|
||||||
`;
|
|
||||||
list.appendChild(item);
|
|
||||||
});
|
});
|
||||||
el.appendChild(list);
|
return roots;
|
||||||
|
|
||||||
renderCommentsAside();
|
|
||||||
updateStripMarkers();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAsideItemFor(el) {
|
function renderThreadNode(node, anchorHash, variant) {
|
||||||
const comments = [];
|
const comment = node.comment;
|
||||||
el.querySelectorAll('.comment-item').forEach(item => {
|
const wrapper = document.createElement('div');
|
||||||
const meta = item.querySelector('.comment-meta');
|
wrapper.className = `comment-thread${comment.resolvedAt ? ' is-resolved' : ''}`;
|
||||||
const bodyEl = item.querySelector('.comment-body');
|
wrapper.dataset.commentId = comment.id;
|
||||||
comments.push({
|
|
||||||
authorName: meta?.querySelector('strong')?.textContent || 'Anonymous',
|
const item = document.createElement('div');
|
||||||
createdAt: meta?.querySelector('time')?.textContent || '',
|
item.className = variant === 'aside' ? 'comments-aside-item__comment' : 'comment-item';
|
||||||
content: bodyEl?.textContent || '',
|
|
||||||
});
|
const meta = document.createElement('div');
|
||||||
});
|
meta.className = variant === 'aside' ? 'comments-aside-item__meta' : 'comment-meta';
|
||||||
|
const author = document.createElement('strong');
|
||||||
|
author.textContent = comment.authorName || 'Anonymous';
|
||||||
|
const time = document.createElement('time');
|
||||||
|
time.textContent = formatDate(comment.createdAt);
|
||||||
|
meta.append(author, time);
|
||||||
|
if (comment.resolvedAt) {
|
||||||
|
const status = document.createElement('span');
|
||||||
|
status.className = 'comment-status';
|
||||||
|
status.textContent = 'Resolved';
|
||||||
|
meta.appendChild(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = variant === 'aside' ? 'comments-aside-item__body' : 'comment-body';
|
||||||
|
body.textContent = comment.content;
|
||||||
|
item.append(meta, body);
|
||||||
|
|
||||||
|
if (canComment && !comment.resolvedAt) {
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'comment-actions';
|
||||||
|
const reply = document.createElement('button');
|
||||||
|
reply.type = 'button';
|
||||||
|
reply.className = 'comment-action';
|
||||||
|
reply.textContent = 'Reply';
|
||||||
|
reply.addEventListener('click', () => createComment(anchorHash, comment.id));
|
||||||
|
actions.appendChild(reply);
|
||||||
|
if (!comment.parentId) {
|
||||||
|
const resolve = document.createElement('button');
|
||||||
|
resolve.type = 'button';
|
||||||
|
resolve.className = 'comment-action';
|
||||||
|
resolve.textContent = 'Resolve thread';
|
||||||
|
resolve.addEventListener('click', () => resolveThread(comment, anchorHash));
|
||||||
|
actions.appendChild(resolve);
|
||||||
|
}
|
||||||
|
item.appendChild(actions);
|
||||||
|
}
|
||||||
|
wrapper.appendChild(item);
|
||||||
|
|
||||||
|
if (node.children.length) {
|
||||||
|
const replies = document.createElement('div');
|
||||||
|
replies.className = 'comment-thread__replies';
|
||||||
|
node.children.forEach(child => replies.appendChild(renderThreadNode(child, anchorHash, variant)));
|
||||||
|
wrapper.appendChild(replies);
|
||||||
|
}
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineComments(el, anchorHash, comments) {
|
||||||
|
el.querySelector('.comment-list')?.remove();
|
||||||
|
updateCommentButtonState(el, comments);
|
||||||
|
if (!comments.length) return;
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'comment-list';
|
||||||
|
buildCommentForest(comments).forEach(root => list.appendChild(renderThreadNode(root, anchorHash, 'inline')));
|
||||||
|
el.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAsideItem(anchorHash, el, comments) {
|
||||||
if (!comments.length) return null;
|
if (!comments.length) return null;
|
||||||
|
|
||||||
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
||||||
const truncated = targetText.length > 120 ? targetText + '…' : targetText;
|
|
||||||
|
|
||||||
const asideItem = document.createElement('div');
|
const asideItem = document.createElement('div');
|
||||||
asideItem.className = 'comments-aside-item';
|
asideItem.className = 'comments-aside-item';
|
||||||
asideItem.dataset.anchorHash = el.dataset.anchorHash;
|
asideItem.dataset.anchorHash = anchorHash;
|
||||||
|
|
||||||
const targetEl = document.createElement('div');
|
|
||||||
targetEl.className = 'comments-aside-item__target';
|
|
||||||
targetEl.textContent = truncated || 'Commented section';
|
|
||||||
targetEl.addEventListener('click', () => {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
});
|
|
||||||
asideItem.appendChild(targetEl);
|
|
||||||
|
|
||||||
comments.forEach(c => {
|
|
||||||
const commentEl = document.createElement('div');
|
|
||||||
commentEl.className = 'comments-aside-item__comment';
|
|
||||||
commentEl.innerHTML = `
|
|
||||||
<div class="comments-aside-item__meta"><strong>${escapeHtml(c.authorName)}</strong><time>${escapeHtml(c.createdAt)}</time></div>
|
|
||||||
<div class="comments-aside-item__body">${escapeHtml(c.content)}</div>
|
|
||||||
`;
|
|
||||||
asideItem.appendChild(commentEl);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const target = document.createElement('button');
|
||||||
|
target.className = 'comments-aside-item__target';
|
||||||
|
target.type = 'button';
|
||||||
|
target.textContent = el.dataset.commentTarget || 'Commented section';
|
||||||
|
target.addEventListener('click', () => el.scrollIntoView({ behavior: 'smooth', block: 'center' }));
|
||||||
|
asideItem.appendChild(target);
|
||||||
|
buildCommentForest(comments).forEach(root => asideItem.appendChild(renderThreadNode(root, anchorHash, 'aside')));
|
||||||
return asideItem;
|
return asideItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCommentsInto(container) {
|
function renderCommentsInto(container) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
let rendered = 0;
|
||||||
const body = document.querySelector('.markdown-body');
|
elementsByAnchor.forEach((el, anchorHash) => {
|
||||||
if (!body) return;
|
const item = buildAsideItem(anchorHash, el, commentsByAnchor.get(anchorHash) || []);
|
||||||
|
if (item) {
|
||||||
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
container.appendChild(item);
|
||||||
if (!commentables.length) {
|
rendered++;
|
||||||
container.innerHTML = '<p class="document-comments-aside__empty">No comments yet.</p>';
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
commentables.forEach(el => {
|
|
||||||
const item = buildAsideItemFor(el);
|
|
||||||
if (item) container.appendChild(item);
|
|
||||||
});
|
});
|
||||||
|
if (!rendered) {
|
||||||
|
container.innerHTML = `<p class="document-comments-aside__empty">${includeResolved ? 'No comments found.' : 'No active comments yet.'}</p>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCommentsAside() {
|
function renderCommentsAside() {
|
||||||
@@ -237,177 +276,148 @@
|
|||||||
function updateStripMarkers() {
|
function updateStripMarkers() {
|
||||||
if (!stripTrack) return;
|
if (!stripTrack) return;
|
||||||
stripTrack.innerHTML = '';
|
stripTrack.innerHTML = '';
|
||||||
|
|
||||||
const body = document.querySelector('.markdown-body');
|
|
||||||
const shell = document.querySelector('.document-shell');
|
const shell = document.querySelector('.document-shell');
|
||||||
if (!body || !shell) return;
|
if (!shell) return;
|
||||||
|
elementsByAnchor.forEach((el, anchorHash) => {
|
||||||
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
if (!(commentsByAnchor.get(anchorHash) || []).length) return;
|
||||||
const shellRect = shell.getBoundingClientRect();
|
|
||||||
const trackHeight = stripTrack.clientHeight;
|
|
||||||
|
|
||||||
commentables.forEach(el => {
|
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
|
const shellRect = shell.getBoundingClientRect();
|
||||||
const relativeTop = rect.top - shellRect.top + shell.scrollTop;
|
const relativeTop = rect.top - shellRect.top + shell.scrollTop;
|
||||||
const percent = (relativeTop / shell.scrollHeight) * 100;
|
const percent = (relativeTop / shell.scrollHeight) * 100;
|
||||||
const clamped = Math.max(0, Math.min(95, percent));
|
|
||||||
|
|
||||||
const marker = document.createElement('button');
|
const marker = document.createElement('button');
|
||||||
marker.className = 'document-comments-strip__marker';
|
marker.className = 'document-comments-strip__marker';
|
||||||
marker.type = 'button';
|
marker.type = 'button';
|
||||||
marker.style.top = clamped + '%';
|
marker.style.top = Math.max(0, Math.min(95, percent)) + '%';
|
||||||
marker.dataset.anchorHash = el.dataset.anchorHash;
|
marker.dataset.anchorHash = anchorHash;
|
||||||
marker.title = 'Jump to comments';
|
marker.title = 'Jump to comments';
|
||||||
marker.addEventListener('click', () => {
|
marker.addEventListener('click', () => {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
if (commentsHidden()) setCommentsHidden(false);
|
if (commentsHidden()) setCommentsHidden(false);
|
||||||
// Highlight the corresponding sidebar item
|
commentsAsideList?.querySelector(`[data-anchor-hash="${anchorHash}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
const asideItem = commentsAsideList?.querySelector(`[data-anchor-hash="${el.dataset.anchorHash}"]`);
|
|
||||||
if (asideItem) {
|
|
||||||
asideItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
asideItem.classList.add('is-highlighted');
|
|
||||||
setTimeout(() => asideItem.classList.remove('is-highlighted'), 1500);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
stripTrack.appendChild(marker);
|
stripTrack.appendChild(marker);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let bidirectionalHoverInitialized = false;
|
|
||||||
function initBidirectionalHover() {
|
function initBidirectionalHover() {
|
||||||
if (bidirectionalHoverInitialized) return;
|
|
||||||
bidirectionalHoverInitialized = true;
|
|
||||||
|
|
||||||
const body = document.querySelector('.markdown-body');
|
const body = document.querySelector('.markdown-body');
|
||||||
if (!body || !commentsAsideList) return;
|
if (!body || !commentsAsideList) return;
|
||||||
|
body.addEventListener('mouseover', event => toggleHighlight(event.target.closest('.commentable'), true));
|
||||||
// Body → sidebar + marker
|
body.addEventListener('mouseout', event => toggleHighlight(event.target.closest('.commentable'), false));
|
||||||
body.addEventListener('mouseenter', (e) => {
|
commentsAsideList.addEventListener('mouseover', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), true));
|
||||||
const el = e.target.closest('.commentable[data-has-comments="true"]');
|
commentsAsideList.addEventListener('mouseout', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), false));
|
||||||
if (!el) return;
|
|
||||||
const hash = el.dataset.anchorHash;
|
|
||||||
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
||||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
||||||
if (asideItem) asideItem.classList.add('is-highlighted');
|
|
||||||
if (marker) marker.classList.add('is-active');
|
|
||||||
el.classList.add('is-highlighted');
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
body.addEventListener('mouseleave', (e) => {
|
|
||||||
const el = e.target.closest('.commentable[data-has-comments="true"]');
|
|
||||||
if (!el) return;
|
|
||||||
const hash = el.dataset.anchorHash;
|
|
||||||
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
||||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
||||||
if (asideItem) asideItem.classList.remove('is-highlighted');
|
|
||||||
if (marker) marker.classList.remove('is-active');
|
|
||||||
el.classList.remove('is-highlighted');
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
// Sidebar → paragraph + marker (event delegation so it survives re-renders)
|
|
||||||
commentsAsideList.addEventListener('mouseenter', (e) => {
|
|
||||||
const item = e.target.closest('.comments-aside-item');
|
|
||||||
if (!item) return;
|
|
||||||
const hash = item.dataset.anchorHash;
|
|
||||||
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
||||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
||||||
if (para) para.classList.add('is-highlighted');
|
|
||||||
if (marker) marker.classList.add('is-active');
|
|
||||||
item.classList.add('is-highlighted');
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
commentsAsideList.addEventListener('mouseleave', (e) => {
|
|
||||||
const item = e.target.closest('.comments-aside-item');
|
|
||||||
if (!item) return;
|
|
||||||
const hash = item.dataset.anchorHash;
|
|
||||||
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
||||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
||||||
if (para) para.classList.remove('is-highlighted');
|
|
||||||
if (marker) marker.classList.remove('is-active');
|
|
||||||
item.classList.remove('is-highlighted');
|
|
||||||
}, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function toggleHighlight(el, active) {
|
||||||
const div = document.createElement('div');
|
if (!el) return;
|
||||||
div.textContent = str;
|
const hash = el.dataset.anchorHash;
|
||||||
return div.innerHTML;
|
el.classList.toggle('is-highlighted', active);
|
||||||
|
commentsAsideList?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-highlighted', active);
|
||||||
|
stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-active', active);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso) {
|
function toggleAsideHighlight(item, active) {
|
||||||
const d = new Date(iso);
|
if (!item) return;
|
||||||
return d.toLocaleString();
|
toggleHighlight(elementsByAnchor.get(item.dataset.anchorHash), active);
|
||||||
}
|
|
||||||
|
|
||||||
function commentsSheetOpen() {
|
|
||||||
return commentsAside?.classList.contains('is-open');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCommentsSheetOpen(open) {
|
function setCommentsSheetOpen(open) {
|
||||||
if (!commentsAside) return;
|
if (!commentsAside) return;
|
||||||
commentsAside.classList.toggle('is-open', open);
|
commentsAside.classList.toggle('is-open', open);
|
||||||
if (commentsSheetToggle) {
|
commentsSheetToggle?.setAttribute('aria-expanded', String(open));
|
||||||
commentsSheetToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
||||||
commentsSheetToggle.setAttribute('aria-label', open ? 'Hide comments' : 'Show comments');
|
|
||||||
}
|
|
||||||
if (open) {
|
if (open) {
|
||||||
document.body.classList.add('drawer-open');
|
document.body.classList.add('drawer-open');
|
||||||
renderCommentsAside();
|
renderCommentsAside();
|
||||||
} else {
|
} else if (!document.querySelector('[data-picker-drawer].is-open, [data-meta-drawer].is-open')) {
|
||||||
if (!pickerOpen() && !metaOpen()) {
|
document.body.classList.remove('drawer-open');
|
||||||
document.body.classList.remove('drawer-open');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickerOpen() {
|
commentsSheetToggle?.addEventListener('click', () => setCommentsSheetOpen(!commentsAside?.classList.contains('is-open')));
|
||||||
return document.querySelector('[data-picker-drawer]')?.classList.contains('is-open');
|
commentsSheetClose?.addEventListener('click', () => setCommentsSheetOpen(false));
|
||||||
|
|
||||||
|
function setWatchState(nextWatching) {
|
||||||
|
watching = nextWatching;
|
||||||
|
if (!watchButton) return;
|
||||||
|
watchButton.setAttribute('aria-pressed', String(watching));
|
||||||
|
const label = watchButton.querySelector('[data-document-watch-label]');
|
||||||
|
if (label) label.textContent = watching ? 'Unwatch document' : 'Watch document';
|
||||||
}
|
}
|
||||||
|
|
||||||
function metaOpen() {
|
async function loadWatchStatus() {
|
||||||
return document.querySelector('[data-meta-drawer]')?.classList.contains('is-open');
|
if (!watchButton) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/watch/status?document_id=${encodeURIComponent(documentId)}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json();
|
||||||
|
setWatchState(Boolean(data.watching));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('load watch status', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commentsSheetToggle) {
|
async function toggleWatch() {
|
||||||
commentsSheetToggle.addEventListener('click', () => {
|
if (!watchButton) return;
|
||||||
setCommentsSheetOpen(!commentsSheetOpen());
|
watchButton.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/watch/document', {
|
||||||
|
method: watching ? 'DELETE' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ documentId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
setWatchState(!watching);
|
||||||
|
} catch (err) {
|
||||||
|
window.alert('Failed to update watch: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
watchButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watchButton?.addEventListener('click', toggleWatch);
|
||||||
|
|
||||||
|
function updateHistoryButtons() {
|
||||||
|
historyButtons.forEach(button => {
|
||||||
|
button.setAttribute('aria-pressed', String(includeResolved));
|
||||||
|
button.textContent = includeResolved ? 'Hide resolved' : 'Show resolved';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commentsSheetClose) {
|
historyButtons.forEach(button => button.addEventListener('click', async () => {
|
||||||
commentsSheetClose.addEventListener('click', () => setCommentsSheetOpen(false));
|
includeResolved = !includeResolved;
|
||||||
|
updateHistoryButtons();
|
||||||
|
await Promise.all(Array.from(elementsByAnchor.keys(), loadCommentsForAnchor));
|
||||||
|
}));
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
const body = document.querySelector('.markdown-body');
|
const body = document.querySelector('.markdown-body');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
const commentables = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
|
||||||
const paragraphs = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
|
commentables.forEach(el => {
|
||||||
paragraphs.forEach(el => {
|
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||||
const hash = hashParagraph(el);
|
const anchorHash = hashParagraph(el);
|
||||||
if (!hash) return;
|
if (!anchorHash) return;
|
||||||
el.dataset.anchorHash = hash;
|
el.dataset.anchorHash = anchorHash;
|
||||||
|
el.dataset.commentTarget = targetText.length > 120 ? targetText.slice(0, 120) + '…' : targetText;
|
||||||
el.classList.add('commentable');
|
el.classList.add('commentable');
|
||||||
if (canComment) addCommentButton(el, hash);
|
elementsByAnchor.set(anchorHash, el);
|
||||||
loadCommentsForAnchor(el, hash);
|
if (canComment) addCommentButton(el, anchorHash);
|
||||||
|
loadCommentsForAnchor(anchorHash);
|
||||||
});
|
});
|
||||||
|
|
||||||
renderCommentsAside();
|
renderCommentsAside();
|
||||||
initBidirectionalHover();
|
initBidirectionalHover();
|
||||||
|
loadWatchStatus();
|
||||||
// Update markers on scroll/resize
|
updateHistoryButtons();
|
||||||
const shell = document.querySelector('.document-shell');
|
const shell = document.querySelector('.document-shell');
|
||||||
if (shell) {
|
shell?.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
|
||||||
shell.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers));
|
window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers));
|
||||||
// Initial marker update after layout settles
|
|
||||||
setTimeout(updateStripMarkers, 100);
|
setTimeout(updateStripMarkers, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
else init();
|
||||||
} else {
|
})();
|
||||||
init();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
const trigger = bell.querySelector('.notification-bell__trigger');
|
const trigger = bell.querySelector('.notification-bell__trigger');
|
||||||
const countEl = bell.querySelector('[data-unread-count]');
|
const countEl = bell.querySelector('[data-unread-count]');
|
||||||
const dropdown = bell.querySelector('[data-notification-dropdown]');
|
const dropdown = bell.querySelector('[data-notification-dropdown]');
|
||||||
|
if (!trigger || !countEl || !dropdown) return;
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
|
|
||||||
trigger.addEventListener('click', (e) => {
|
trigger.addEventListener('click', (e) => {
|
||||||
@@ -76,31 +77,42 @@
|
|||||||
notifications.forEach(n => {
|
notifications.forEach(n => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = n.readAt ? 'notification-item is-read' : 'notification-item';
|
li.className = n.readAt ? 'notification-item is-read' : 'notification-item';
|
||||||
li.innerHTML = `
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'notification-item__button';
|
||||||
|
button.innerHTML = `
|
||||||
<div class="notification-message">${escapeHtml(n.message)}</div>
|
<div class="notification-message">${escapeHtml(n.message)}</div>
|
||||||
<time class="notification-time">${formatDate(n.createdAt)}</time>
|
<time class="notification-time">${formatDate(n.createdAt)}</time>
|
||||||
`;
|
`;
|
||||||
if (!n.readAt) {
|
button.addEventListener('click', async () => {
|
||||||
li.addEventListener('click', () => markRead(n.id));
|
if (!n.readAt) await markRead(n.id, false);
|
||||||
}
|
const target = notificationURL(n);
|
||||||
|
if (target) window.location.assign(target);
|
||||||
|
});
|
||||||
|
li.appendChild(button);
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add "Mark all read" button
|
// Add "Mark all read" button
|
||||||
const footer = document.createElement('li');
|
if (notifications.some(notification => !notification.readAt)) {
|
||||||
footer.className = 'notification-footer';
|
const footer = document.createElement('li');
|
||||||
const btn = document.createElement('button');
|
footer.className = 'notification-footer';
|
||||||
btn.textContent = 'Mark all read';
|
const btn = document.createElement('button');
|
||||||
btn.addEventListener('click', markAllRead);
|
btn.textContent = 'Mark all read';
|
||||||
footer.appendChild(btn);
|
btn.addEventListener('click', markAllRead);
|
||||||
list.appendChild(footer);
|
footer.appendChild(btn);
|
||||||
|
list.appendChild(footer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(id) {
|
async function markRead(id, refresh = true) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
|
const response = await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: 'POST' });
|
||||||
updateBell();
|
if (!response.ok) throw new Error(await response.text());
|
||||||
loadNotifications();
|
if (refresh) {
|
||||||
|
updateBell();
|
||||||
|
loadNotifications();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('mark read', err);
|
console.error('mark read', err);
|
||||||
}
|
}
|
||||||
@@ -108,7 +120,8 @@
|
|||||||
|
|
||||||
async function markAllRead() {
|
async function markAllRead() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/notifications/read-all', { method: 'POST' });
|
const response = await fetch('/api/notifications/read-all', { method: 'POST' });
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
updateBell();
|
updateBell();
|
||||||
loadNotifications();
|
loadNotifications();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -127,6 +140,12 @@
|
|||||||
return d.toLocaleString();
|
return d.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notificationURL(notification) {
|
||||||
|
if (notification.resourceType !== 'document' || !notification.resourceId) return '';
|
||||||
|
const path = notification.resourceId.replace(/^doc:/, '').split('/').map(encodeURIComponent).join('/');
|
||||||
|
return '/docs/' + path;
|
||||||
|
}
|
||||||
|
|
||||||
// Listen for WebSocket notification events
|
// Listen for WebSocket notification events
|
||||||
if (window.__cairnquireRealtime) {
|
if (window.__cairnquireRealtime) {
|
||||||
window.__cairnquireRealtime.on('notification', () => {
|
window.__cairnquireRealtime.on('notification', () => {
|
||||||
|
|||||||
@@ -198,19 +198,27 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-item {
|
.notification-item {
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-item__button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.notification-item:hover {
|
.notification-item:hover {
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-item.is-read {
|
.notification-item.is-read {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-message {
|
.notification-message {
|
||||||
@@ -1199,6 +1207,27 @@ code {
|
|||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comments-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-toolbar button,
|
||||||
|
.comment-action {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-toolbar button {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Bidirectional hover highlighting */
|
/* Bidirectional hover highlighting */
|
||||||
.comments-aside-item {
|
.comments-aside-item {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
@@ -1216,6 +1245,13 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.comments-aside-item__target {
|
.comments-aside-item__target {
|
||||||
|
display: block;
|
||||||
|
width: calc(100% + 1.2rem);
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
border-top: 0;
|
||||||
|
border-right: 0;
|
||||||
|
border-left: 0;
|
||||||
margin: -0.6rem -0.6rem 0.4rem;
|
margin: -0.6rem -0.6rem 0.4rem;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -1259,6 +1295,39 @@ code {
|
|||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-thread__replies {
|
||||||
|
margin-left: 0.7rem;
|
||||||
|
padding-left: 0.65rem;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-thread.is-resolved {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-status {
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-action {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-action:hover,
|
||||||
|
.comments-toolbar button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.document-comments-aside__empty {
|
.document-comments-aside__empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
|
|||||||
@@ -107,6 +107,7 @@
|
|||||||
<script src="/static/cache.js" defer></script>
|
<script src="/static/cache.js" defer></script>
|
||||||
<script src="/static/sync.js" defer></script>
|
<script src="/static/sync.js" defer></script>
|
||||||
<script src="/static/realtime.js" defer></script>
|
<script src="/static/realtime.js" defer></script>
|
||||||
|
<script src="/static/notification-bell.js" defer></script>
|
||||||
<script src="/static/editor.js" defer></script>
|
<script src="/static/editor.js" defer></script>
|
||||||
<script src="/static/auth.js" defer></script>
|
<script src="/static/auth.js" defer></script>
|
||||||
<script src="/static/render.js" defer></script>
|
<script src="/static/render.js" defer></script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<section class="workspace-shell workspace-shell--document">
|
<section class="workspace-shell workspace-shell--document">
|
||||||
<div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div>
|
<div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div>
|
||||||
{{ template "browser" .Browser }}
|
{{ template "browser" .Browser }}
|
||||||
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}">
|
<article class="document-shell" data-document-id="{{ .ID }}" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}">
|
||||||
<div class="document-mobile-bar">
|
<div class="document-mobile-bar">
|
||||||
<button class="document-mobile-bar__btn document-mobile-bar__btn--picker" type="button" data-toggle-picker aria-label="Open file picker" aria-expanded="false">
|
<button class="document-mobile-bar__btn document-mobile-bar__btn--picker" type="button" data-toggle-picker aria-label="Open file picker" aria-expanded="false">
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg>
|
||||||
@@ -43,6 +43,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="document-actions-dropdown__menu">
|
<div class="document-actions-dropdown__menu">
|
||||||
|
<button class="document-actions-dropdown__item" type="button" data-document-watch aria-pressed="false">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"></path></svg>
|
||||||
|
<span data-document-watch-label>Watch document</span>
|
||||||
|
</button>
|
||||||
{{ if .CanWrite }}
|
{{ if .CanWrite }}
|
||||||
<a class="document-actions-dropdown__item" href="/docs/{{ trimMd .Path }}/edit">
|
<a class="document-actions-dropdown__item" href="/docs/{{ trimMd .Path }}/edit">
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||||
@@ -106,6 +110,9 @@
|
|||||||
<div class="document-comments-strip__track" data-comments-strip-track></div>
|
<div class="document-comments-strip__track" data-comments-strip-track></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="document-comments-panel">
|
<div class="document-comments-panel">
|
||||||
|
<div class="comments-toolbar">
|
||||||
|
<button type="button" data-comments-history aria-pressed="false">Show resolved</button>
|
||||||
|
</div>
|
||||||
<div class="document-comments-panel__list" data-comments-aside-list></div>
|
<div class="document-comments-panel__list" data-comments-aside-list></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -126,6 +133,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="document-comments-sheet__panel">
|
<div class="document-comments-sheet__panel">
|
||||||
|
<div class="comments-toolbar">
|
||||||
|
<button type="button" data-comments-history aria-pressed="false">Show resolved</button>
|
||||||
|
</div>
|
||||||
<div class="document-comments-panel__list" data-comments-aside-list-mobile></div>
|
<div class="document-comments-panel__list" data-comments-aside-list-mobile></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ services:
|
|||||||
CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content"
|
CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content"
|
||||||
CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files"
|
CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files"
|
||||||
CAIRNQUIRE_PUBLIC_ORIGIN: "${CAIRNQUIRE_PUBLIC_ORIGIN:?Set CAIRNQUIRE_PUBLIC_ORIGIN in .env}"
|
CAIRNQUIRE_PUBLIC_ORIGIN: "${CAIRNQUIRE_PUBLIC_ORIGIN:?Set CAIRNQUIRE_PUBLIC_ORIGIN in .env}"
|
||||||
|
CAIRNQUIRE_EMAIL_PROVIDER: "${CAIRNQUIRE_EMAIL_PROVIDER:-smtp}"
|
||||||
CAIRNQUIRE_EMAIL_SMTP_HOST: "${CAIRNQUIRE_EMAIL_SMTP_HOST:-mailpit}"
|
CAIRNQUIRE_EMAIL_SMTP_HOST: "${CAIRNQUIRE_EMAIL_SMTP_HOST:-mailpit}"
|
||||||
CAIRNQUIRE_EMAIL_SMTP_PORT: "${CAIRNQUIRE_EMAIL_SMTP_PORT:-1025}"
|
CAIRNQUIRE_EMAIL_SMTP_PORT: "${CAIRNQUIRE_EMAIL_SMTP_PORT:-1025}"
|
||||||
|
CAIRNQUIRE_EMAIL_POSTMARK_TOKEN: "${CAIRNQUIRE_EMAIL_POSTMARK_TOKEN:-}"
|
||||||
|
CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID: "${CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID:-}"
|
||||||
|
CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN: "${CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN:-}"
|
||||||
CAIRNQUIRE_EMAIL_FROM: "${CAIRNQUIRE_EMAIL_FROM:-notifications@cairnquire.local}"
|
CAIRNQUIRE_EMAIL_FROM: "${CAIRNQUIRE_EMAIL_FROM:-notifications@cairnquire.local}"
|
||||||
CAIRNQUIRE_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}"
|
CAIRNQUIRE_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Reference in New Issue
Block a user