Add Cloudflare email and collaboration workflows

This commit is contained in:
2026-07-22 12:59:16 -04:00
parent 09f51d1ef4
commit 4454e918c2
35 changed files with 1596 additions and 487 deletions

View File

@@ -1,11 +1,24 @@
# Required for deployed passkeys and device authorization URLs.
CAIRNQUIRE_PUBLIC_ORIGIN=https://cairnquire.example.com
# Optional SMTP settings. The bundled Compose file defaults to its local
# Mailpit service so notifications can be inspected during initial deployment.
# Outbound provider: smtp, postmark, cloudflare, or noop. Compose defaults to
# smtp with Mailpit so messages can be inspected during initial deployment.
CAIRNQUIRE_EMAIL_PROVIDER=smtp
CAIRNQUIRE_EMAIL_SMTP_HOST=mailpit
CAIRNQUIRE_EMAIL_SMTP_PORT=1025
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.
CAIRNQUIRE_LOG_LEVEL=INFO

View File

@@ -1,64 +1,74 @@
# Project Status Report
**Generated:** 2026-06-24
**Updated:** 2026-07-22
**Branch:** main
**Last Commit:** b6d2ded - passkey existing (2026-06-16)
**Last Commit:** 09f51d1 - Add queued email notifications for collaboration events
## Quick Stats
| Metric | Value |
|--------|-------|
| Total Tracked Files | 189 |
| Go Files | 46 |
| Swift Files | 14 (macOS app) |
| Test Files | 10 |
| Avg Test Coverage | ~39% (markdown 87%, store 70%, auth 55%, sync 54%) |
| Total Tracked Files | 201 |
| Go Files | 63 |
| Swift Files | 15 (macOS app and package manifest) |
| Go Test Files | 18 |
| Aggregate Go Test Coverage | 52.4% |
| 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):
71 files changed, +7908 / -812 lines.
- `go test -coverpkg=./... -coverprofile=... ./...` passes with 52.4% aggregate statement coverage.
- `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:
- `b6d2ded` passkey existing-user login flow
- `4655008` text editor link autocomplete
- `0adb980` CodeMirror editor integration
- `c39c50b` dropdown menu + comment side panel (wires M4 comment UI)
- `6c30fd8` sync service update
- `04a1f2b` ops design + app scaffolding (`entrypoint.sh`)
- `632621b`/`1e939f3` macOS menu-bar app: live search popover, drop-target icon
- `ddc7d5c` accounts and email wiring
- `93e96be`/`73d505a` UI CSS expansion (~1900 lines) and workspace browser polish
- New untracked subsystems: `permissions_handlers.go` (RBAC sharing UI), `tags.js`/`tag.gohtml` (tagging), `keyboard-nav.js`, `mobile.js`, `archive.js`
## Recent Activity
Recent work has moved the project beyond the previous 2026-06-24 report:
- Durable email queue with retry/backoff and failed-delivery inspection.
- Postmark outbound sender with API-level tests.
- Cloudflare Email Service REST sender with retry/permanent-error classification and API-level tests.
- File-change and comment notifications now enqueue email delivery.
- Notification bell/list/read UI is active.
- Document watch/unwatch controls are active, watcher subscriptions are deduplicated, and ancestor-folder matching is fixed.
- 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
- [x] Milestone 1: Foundation
- [x] Milestone 2: Sync Protocol
- [x] Milestone 3: Authentication
- [ ] Milestone 4: Collaboration (comments UI wired; email integration in progress; digest/preferences pending)
- [ ] Milestone 5: Search & UI (server FTS5 done; design-system CSS, keyboard nav, mobile responsive shipped; `/design` route, Flexsearch, a11y audits pending)
- [ ] Milestone 6: Production (not started)
- [ ] 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, tags, keyboard navigation, and responsive foundations shipped; offline client search, `/design`, performance, and accessibility validation remain)
- [ ] 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.
- **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.
## Known Gaps Requiring Human Review
## Notes for Human Review
- `collaboration.Service` defines its own `EmailSender` interface duplicating `email.Sender`. Consolidate when convenient.
- `Service.notifyWatchersOfComment` and `NotifyDocumentChanged` create in-app notifications only; never invoke `s.email`. `Notification.EmailedAt` field unused.
- `email.SMTPSender` uses unauthenticated SMTP (fine for local Mailpit, not Postmark).
- 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.
- Content-hash downloads are not tied back to effective document permission.
- Browser caches are not cleared on logout.
- 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.*

View File

@@ -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.
## 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

View File

@@ -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
- Index rebuilds incrementally on file changes
### 7. Email (Postmark)
- Go adapter using Postmark HTTP API
### 7. Email (Postmark or Cloudflare Email Service)
- 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)
- Threading via Message-ID / In-Reply-To headers
- Webhook endpoint receives email replies, converts to comments
- Templates: notification, digest, conflict alert
- Planned: threading via Message-ID / In-Reply-To headers
- Planned: verified inbound webhook converts email replies to comments
- Implemented templates: file change, comment, mention, and conflict; digest remains planned
### 8. File Store (Content-Addressed)
- Files stored by SHA-256 hash: `store/ab/cd/abcdef1234...`

View File

@@ -9,9 +9,9 @@
- [x] Comment system (backend + UI wired)
- [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] 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] Notification system (backend complete)
@@ -21,10 +21,10 @@
- [ ] Global notification settings
- [ ] Digest mode (hourly/daily batches)
- [x] Web notification UI (backend complete, JS partial)
- [x] Notification bell with unread count - API exists
- [x] Notification list dropdown - API exists
- [x] Mark as read functionality - API exists
- [x] Web notification UI
- [x] Notification bell with unread count
- [x] Notification list dropdown
- [x] Mark one/all as read functionality
- [ ] Notification preferences page
### Week 8: Email Integration & Conflict Resolution
@@ -34,6 +34,7 @@
- [x] Postmark adapter (`email/postmark.go`)
- [x] Plain-text email templates (`email/templates.go`)
- [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
- [x] Email notification types
@@ -60,20 +61,20 @@
### Functional
- [x] Users can add comments to specific lines in a document (API exists)
- [x] Comments are linked to document version (hash)
- [x] Comment threading works (reply to reply) (schema supports it)
- [x] Users can watch files or folders for changes (API exists)
- [x] Email notifications sent within 1 minute of event (Postmark adapter + queue with retry)
- [x] Comment threading works (reply to reply) in the browser and API
- [x] Users can watch files or folders for changes (document UI + APIs)
- [x] Email notifications queued within 1 minute of event (Postmark, Cloudflare, or SMTP sender)
- [ ] Replying to notification email creates a comment
- [ ] 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)
- [ ] Resolved comments are hidden but accessible in history
- [x] Resolved comments are hidden but accessible in history
### Non-Functional
- [x] Emails are plain-text only (no HTML) — enforced by templates, verified by test
- [ ] Email threading works in Gmail, Outlook, Apple Mail
- [ ] 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
### Email Template Examples
@@ -166,7 +167,7 @@ CREATE TABLE watchers (
- [ ] All acceptance criteria pass
- [ ] 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
- [ ] Notification preferences persist across sessions
- [ ] Digest mode tested with 24-hour window

View File

@@ -150,19 +150,19 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
**Goal:** Comments, notifications, and conflict resolution
- [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)
- [ ] 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
**Acceptance Criteria:**
- [x] Users can comment on specific lines/sections of a document (API)
- [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
- [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
---
@@ -265,7 +265,7 @@ See [architecture-overview.md](architecture-overview.md) for detailed component
| Markdown | `github.com/yuin/goldmark` + extensions | Fast, extensible, CommonMark compliant |
| WebSocket | `github.com/gorilla/websocket` | Most widely used Go WS library |
| 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 |
| 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 |
| 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 |
| 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 |
| 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
3. Complete Milestone 3: Authentication
4. 🔄 Finish Milestone 4: Collaboration
- Wire up comment UI in browser (JS exists, needs integration)
- Wire up notification bell UI
- Implement actual email sending (Postmark or SMTP)
- Add notification preferences page
- Test watcher-driven notifications end-to-end
- Complete inbound email replies, mention notifications, and conflict notifications
- Add global/per-resource notification preferences and digest scheduling
- Test watcher-driven notifications against live Postmark and Cloudflare accounts
5. 🔄 Finish Milestone 5: Search & UI Polish
- Tag filtering in search
- Design system /design route
@@ -395,11 +393,11 @@ cairnquire/
- Mobile-responsive layout polish
- Accessibility improvements
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
- Security audit checklist
- Backup/restore scripts
- Health check and monitoring endpoints
- Complete dependency health checks and add monitoring endpoints
---

View File

@@ -53,8 +53,12 @@ deployment, only `CAIRNQUIRE_PUBLIC_ORIGIN` must be supplied in `.env`.
| Variable | Required | Compose default | Description |
|----------|----------|-----------------|-------------|
| `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_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_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_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
Compose mounts:

View File

@@ -116,8 +116,12 @@ Supported variables:
- `CAIRNQUIRE_DATABASE_AUTH_TOKEN` - Optional auth token for the remote libsql primary.
- `CAIRNQUIRE_CONTENT_SOURCE_DIR` - Editable Markdown source directory.
- `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_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_LOG_LEVEL` - Defaults to `INFO`.
- `CAIRNQUIRE_DEV_MODE` - Optional local-only auth shortcut. Do not enable on a deployed server.

View File

@@ -73,6 +73,16 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
var emailQueue *email.Queue
emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName}
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":
pm := email.NewPostmarkSender(cfg.Email.PostmarkToken, cfg.Email.From, cfg.Email.PostmarkAPIURL, logger)
emailSender = pm
@@ -95,7 +105,22 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
EmailQueue: emailQueue,
EmailRenderer: emailRenderer,
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) {

View File

@@ -63,14 +63,17 @@ func (r *Repository) GetComment(ctx context.Context, id string) (*Comment, error
return &c, nil
}
func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string) ([]Comment, error) {
rows, err := r.db.QueryContext(ctx, `
func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) {
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
FROM comments c
JOIN users u ON u.id = c.author_id
WHERE c.document_id = ?
ORDER BY c.created_at ASC
`, documentID)
WHERE c.document_id = ?`
if !includeResolved {
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 {
return nil, fmt.Errorf("list comments: %w", err)
}
@@ -79,14 +82,17 @@ func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID stri
return scanComments(rows)
}
func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
rows, err := r.db.QueryContext(ctx, `
func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) {
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
FROM comments c
JOIN users u ON u.id = c.author_id
WHERE c.document_id = ? AND c.anchor_hash = ? AND c.resolved_at IS NULL
ORDER BY c.created_at ASC
`, documentID, anchorHash)
WHERE c.document_id = ? AND c.anchor_hash = ?`
if !includeResolved {
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 {
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)
}
func (r *Repository) ResolveComment(ctx context.Context, commentID string, userID string) error {
_, err := r.db.ExecContext(ctx, `
func (r *Repository) ResolveCommentThread(ctx context.Context, commentID string, userID string) error {
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 = ?
WHERE id = ?
`, formatTime(time.Now().UTC()), userID, commentID)
WHERE id IN (SELECT id FROM thread) AND resolved_at IS NULL
`, commentID, formatTime(time.Now().UTC()), userID)
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
}
@@ -228,10 +246,12 @@ func (r *Repository) MarkAllNotificationsRead(ctx context.Context, userID string
// Watchers
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, `
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 (?, ?, ?, ?)
ON CONFLICT(user_id, document_id, folder_path) DO NOTHING
`, w.UserID, w.DocumentID, w.FolderPath, formatTime(w.CreatedAt))
if err != nil {
return fmt.Errorf("add watcher: %w", err)
@@ -262,12 +282,12 @@ func (r *Repository) ListWatchersByDocument(ctx context.Context, documentID stri
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, `
SELECT user_id, document_id, folder_path, created_at
FROM watchers
WHERE folder_path = ? OR ? || '/' = substr(folder_path, 1, length(?) + 1)
`, folderPath, folderPath, folderPath)
WHERE folder_path = '' OR folder_path || '/' = substr(?, 1, length(folder_path) + 1)
`, documentPath)
if err != nil {
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)
}
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) {
var count int
err := r.db.QueryRowContext(ctx, `

View 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")
}
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"log/slog"
"net/url"
"strings"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
@@ -50,6 +52,8 @@ type UserLookup interface {
GetUserByID(ctx context.Context, userID string) (auth.User, error)
}
type DocumentReadAuthorizer func(ctx context.Context, userID, documentID string) (bool, error)
type NoOpEmailSender struct{}
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 {
repo *Repository
email EmailSender
queue EmailQueue
renderer EmailRenderer
users UserLookup
hub *realtime.Hub
logger *slog.Logger
publicOrigin string
repo *Repository
email EmailSender
queue EmailQueue
renderer EmailRenderer
users UserLookup
canReadDocument DocumentReadAuthorizer
hub *realtime.Hub
logger *slog.Logger
publicOrigin string
}
type Options struct {
EmailSender EmailSender
EmailQueue EmailQueue
EmailRenderer EmailRenderer
UserLookup UserLookup
PublicOrigin string
EmailSender EmailSender
EmailQueue EmailQueue
EmailRenderer EmailRenderer
UserLookup UserLookup
CanReadDocument DocumentReadAuthorizer
PublicOrigin string
}
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{}
}
return &Service{
repo: repo,
email: opts.EmailSender,
queue: opts.EmailQueue,
renderer: opts.EmailRenderer,
users: opts.UserLookup,
hub: hub,
logger: logger,
publicOrigin: opts.PublicOrigin,
repo: repo,
email: opts.EmailSender,
queue: opts.EmailQueue,
renderer: opts.EmailRenderer,
users: opts.UserLookup,
canReadDocument: opts.CanReadDocument,
hub: hub,
logger: logger,
publicOrigin: opts.PublicOrigin,
}
}
@@ -106,12 +113,35 @@ func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, i
if principal.UserID == "" {
return nil, fmt.Errorf("authentication required")
}
input.Content = strings.TrimSpace(input.Content)
if input.Content == "" {
return nil, fmt.Errorf("content is required")
}
if input.DocumentID == "" {
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{
ID: randomID("comment"),
@@ -135,23 +165,27 @@ func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, i
return nil, err
}
// Create notifications for watchers
if err := s.notifyWatchersOfComment(ctx, comment); err != nil {
created, err := s.repo.GetComment(ctx, comment.ID)
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)
}
// Broadcast to connected clients
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) {
return s.repo.ListCommentsByDocument(ctx, documentID)
func (s *Service) ListComments(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) {
return s.repo.ListCommentsByDocument(ctx, documentID, includeResolved)
}
func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash)
func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) {
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash, includeResolved)
}
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 == "" {
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
@@ -212,6 +253,9 @@ func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, d
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
if strings.TrimSpace(documentID) == "" {
return fmt.Errorf("document_id is required")
}
return s.repo.AddWatcher(ctx, Watcher{
UserID: principal.UserID,
DocumentID: &documentID,
@@ -223,6 +267,9 @@ func (s *Service) UnwatchDocument(ctx context.Context, principal auth.Principal,
if principal.UserID == "" {
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)
}
@@ -230,6 +277,10 @@ func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, fol
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
folderPath, err := NormalizeFolderPath(folderPath)
if err != nil {
return err
}
return s.repo.AddWatcher(ctx, Watcher{
UserID: principal.UserID,
FolderPath: &folderPath,
@@ -241,6 +292,10 @@ func (s *Service) UnwatchFolder(ctx context.Context, principal auth.Principal, f
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
folderPath, err := NormalizeFolderPath(folderPath)
if err != nil {
return err
}
return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath)
}
@@ -269,6 +324,9 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
continue
}
seen[w.UserID] = true
if !s.watcherCanRead(ctx, w.UserID, documentID) {
continue
}
msg := fmt.Sprintf("%s updated %s", actorName, documentPath)
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 {
watchers, err := s.repo.ListWatchersByDocument(ctx, comment.DocumentID)
watchers, err := s.repo.ListWatchersForDocument(ctx, comment.DocumentID)
if err != nil {
return err
}
seen := make(map[string]bool)
for _, w := range watchers {
if w.UserID == comment.AuthorID {
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")
if comment.AnchorHash != nil {
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
}
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{
ActorName: comment.AuthorName,
CommentBody: comment.Content,
ViewURL: s.documentURL(comment.DocumentID),
ActorName: comment.AuthorName,
DocumentPath: documentPathFromID(comment.DocumentID) + ".md",
CommentBody: comment.Content,
ViewURL: s.documentURL(comment.DocumentID),
})
}
return nil
@@ -349,7 +416,7 @@ func (s *Service) enqueueNotificationEmail(ctx context.Context, notificationID,
return
}
if data.ActorName == "" {
data.ActorName = user.DisplayName
data.ActorName = "Someone"
}
subject, body, err := s.renderer.Render(kind, data)
if err != nil {
@@ -365,7 +432,25 @@ func (s *Service) documentURL(documentPath string) string {
if s.publicOrigin == "" {
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) {

View File

@@ -4,10 +4,27 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"strings"
"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 {
buf := make([]byte, 12)
if _, err := rand.Read(buf); err != nil {

View File

@@ -39,15 +39,18 @@ type AuthConfig struct {
type EmailConfig struct {
// Provider selects the sender implementation. Valid values: "noop",
// "smtp", "postmark". When empty, the loader infers from the other
// fields: PostmarkToken wins, then SMTPHost, then NoOp.
Provider string `json:"provider"`
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
PostmarkToken string `json:"postmarkToken"`
PostmarkAPIURL string `json:"postmarkApiUrl"`
From string `json:"from"`
InstanceName string `json:"instanceName"`
// "smtp", "postmark", or "cloudflare". When empty, the loader infers
// from credentials, then SMTPHost, then NoOp.
Provider string `json:"provider"`
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
PostmarkToken string `json:"postmarkToken"`
PostmarkAPIURL string `json:"postmarkApiUrl"`
CloudflareAccountID string `json:"cloudflareAccountId"`
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
@@ -56,6 +59,9 @@ func (e EmailConfig) ResolvedProvider() string {
if e.Provider != "" {
return e.Provider
}
if e.CloudflareAPIToken != "" || e.CloudflareAccountID != "" {
return "cloudflare"
}
if e.PostmarkToken != "" {
return "postmark"
}
@@ -112,6 +118,9 @@ func Load() (Config, error) {
overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST")
overrideString(&cfg.Email.PostmarkToken, "CAIRNQUIRE_EMAIL_POSTMARK_TOKEN")
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.InstanceName, "CAIRNQUIRE_INSTANCE_NAME")
if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" {

View 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)
}
}

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_watchers_user_folder_unique;
DROP INDEX IF EXISTS idx_watchers_user_document_unique;

View File

@@ -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;

View 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, "; ")
}

View 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")
}
})
}
}

View File

@@ -36,16 +36,21 @@ func NewPostmarkSender(serverToken, from, apiURL string, logger *slog.Logger) *P
serverToken: serverToken,
apiURL: strings.TrimRight(apiURL, "/"),
from: from,
client: &http.Client{Timeout: postmarkTimeout},
logger: logger,
client: &http.Client{
Timeout: postmarkTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
logger: logger,
}
}
type postmarkRequest struct {
From string `json:"From"`
To string `json:"To"`
Subject string `json:"Subject"`
TextBody string `json:"TextBody"`
From string `json:"From"`
To string `json:"To"`
Subject string `json:"Subject"`
TextBody string `json:"TextBody"`
Headers []postmarkHeader `json:"Headers,omitempty"`
}
@@ -66,13 +71,13 @@ type postmarkResponse struct {
// send if they need different reply-to addresses.
func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body string) error {
if p.serverToken == "" {
return fmt.Errorf("postmark server token not configured")
return &PermanentError{Cause: fmt.Errorf("postmark server token not configured")}
}
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 {
return fmt.Errorf("postmark send requires at least one recipient")
return &PermanentError{Cause: fmt.Errorf("postmark send requires at least one recipient")}
}
payload := postmarkRequest{
@@ -96,7 +101,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("postmark request: %w", err)
return &TemporaryError{Cause: fmt.Errorf("postmark request: %w", err)}
}
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 {
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
@@ -117,7 +122,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st
if parsed.ErrorCode != 0 {
// Postmark distinguishes "inactive recipient" (406) and other errors. Treat
// 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)
@@ -139,3 +144,11 @@ type TemporaryError struct {
func (t *TemporaryError) Error() string { return t.Cause.Error() }
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 }

View File

@@ -3,6 +3,7 @@ package email
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"math"
@@ -211,17 +212,28 @@ func (q *Queue) deliver(ctx context.Context, row QueueRow) error {
}
// 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, `
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
`, truncateErr(err.Error()), row.ID); ferr != nil {
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
}
backoff := q.backoff(row.Attempts)
backoff := q.backoff(currentAttempt)
next := time.Now().UTC().Add(backoff)
if _, err := q.db.ExecContext(ctx, `
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)
}
// TemporaryError (Postmark 5xx) is retried with the same backoff as
// other errors; the max-attempts cap still bounds total work.
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", row.Attempts, "backoff", backoff, "error", err)
// Temporary provider failures and unclassified sender failures are retried;
// PermanentError is handled above without wasting additional attempts.
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", currentAttempt, "backoff", backoff, "error", err)
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
@@ -152,9 +153,9 @@ func TestQueue_RetryThenSucceed(t *testing.T) {
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
return sender.successCount >= 1
return sender.successCount.Load() >= 1
}); 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.
@@ -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) {
db := newTestDB(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.
type flakySender struct {
failTimes int
attempts int
successCount int
failTimes int32
attempts atomic.Int32
successCount atomic.Int32
}
func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error {
f.attempts++
if f.attempts <= f.failTimes {
attempt := f.attempts.Add(1)
if attempt <= f.failTimes {
return errors.New("transient failure")
}
f.successCount++
f.successCount.Add(1)
return nil
}

View File

@@ -9,16 +9,16 @@ import (
// TemplateData carries the context required to render any notification email.
// Fields are optional per template; callers should populate what they have.
type TemplateData struct {
ActorName string
ActorEmail string
DocumentPath string
ActorName string
ActorEmail string
DocumentPath string
DocumentTitle string
CommentBody string
MentionText string
ConflictDesc string
ViewURL string
UnsubURL string
InstanceName string
CommentBody string
MentionText string
ConflictDesc string
ViewURL string
UnsubURL string
InstanceName string
}
// Render returns a plain-text subject and body for the given notification
@@ -101,7 +101,7 @@ func renderComment(data TemplateData) (string, string, error) {
} else {
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))
return subject, b.String(), nil
}

View File

@@ -7,12 +7,12 @@ import (
func TestRender_FileChanged(t *testing.T) {
subject, body, err := Render("file_changed", TemplateData{
ActorName: "Alice",
DocumentPath: "docs/api.md",
ActorName: "Alice",
DocumentPath: "docs/api.md",
DocumentTitle: "API Reference",
ViewURL: "https://example.com/docs/api.md",
UnsubURL: "https://example.com/unsub/123",
InstanceName: "Cairnquire",
ViewURL: "https://example.com/docs/api.md",
UnsubURL: "https://example.com/unsub/123",
InstanceName: "Cairnquire",
})
if err != nil {
t.Fatalf("Render: %v", err)
@@ -39,10 +39,10 @@ func TestRender_FileChanged(t *testing.T) {
func TestRender_Comment(t *testing.T) {
subject, body, err := Render("comment", TemplateData{
ActorName: "Bob",
ActorName: "Bob",
DocumentTitle: "Getting Started",
CommentBody: "This step is unclear.\nCan we add an example?",
ViewURL: "https://example.com/docs/getting-started",
CommentBody: "This step is unclear.\nCan we add an example?",
ViewURL: "https://example.com/docs/getting-started",
})
if err != nil {
t.Fatalf("Render: %v", err)
@@ -56,16 +56,16 @@ func TestRender_Comment(t *testing.T) {
if !strings.Contains(body, "> Can we add an example?") {
t.Errorf("body should quote second line: %q", body)
}
if !strings.Contains(body, "Reply to this email") {
t.Errorf("body should mention reply-to-respond: %q", body)
if !strings.Contains(body, "Open the document to reply") {
t.Errorf("body should link users to the supported reply flow: %q", body)
}
}
func TestRender_Mention(t *testing.T) {
subject, body, err := Render("mention", TemplateData{
ActorName: "Carol",
ActorName: "Carol",
DocumentTitle: "Spec",
MentionText: "@dan please review",
MentionText: "@dan please review",
})
if err != nil {
t.Fatalf("Render: %v", err)

View File

@@ -16,6 +16,7 @@ import (
"testing"
"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/database"
"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())
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{
Config: config.Config{
Content: config.ContentConfig{
@@ -108,14 +116,15 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
DevMode: devMode,
},
Logger: logger,
Documents: docService,
Repository: docRepo,
ContentStore: contentStore,
Hub: realtime.NewHub(logger),
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
SyncRepo: syncRepo,
Auth: authService,
Logger: logger,
Documents: docService,
Repository: docRepo,
ContentStore: contentStore,
Hub: hub,
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
SyncRepo: syncRepo,
Auth: authService,
Collaboration: collabService,
})
if err != nil {
t.Fatalf("create http server: %v", err)

View File

@@ -3,6 +3,8 @@ package httpserver
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"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")
includeResolved := r.URL.Query().Get("include_resolved") == "true"
var comments []collaboration.Comment
if anchorHash != "" {
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash)
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash, includeResolved)
} else {
comments, err = s.collaboration.ListComments(r.Context(), documentID)
comments, err = s.collaboration.ListComments(r.Context(), documentID, includeResolved)
}
if err != nil {
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"
limit := 50
if r.URL.Query().Get("limit") != "" {
// Parse limit if needed, but for now just use default
if rawLimit := r.URL.Query().Get("limit"); rawLimit != "" {
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)
@@ -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"})
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 {
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"})
return
}
req.DocumentID = strings.TrimSpace(req.DocumentID)
if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil {
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"})
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)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
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"})
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)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
@@ -290,6 +328,20 @@ func (s *Server) handleIsWatching(w http.ResponseWriter, r *http.Request) {
}
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)
if err != nil {
s.logger.Warn("is watching", "error", err)

View 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))
}
}

View File

@@ -39,6 +39,7 @@ type indexData struct {
type documentData struct {
Browser browserData
ID string
Title string
Path string
Tags []string
@@ -375,6 +376,7 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
BodyTemplate: "document_content",
Data: documentData{
Browser: browser,
ID: record.ID,
Title: page.Title,
Path: page.Path,
Tags: page.Tags,

View File

@@ -207,7 +207,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
case strings.HasPrefix(path, "/api/content/"):
return auth.ScopeSyncRead, true
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"):
return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/watch"):

View File

@@ -1,23 +1,29 @@
(function () {
'use strict';
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash;
const canComment = document.querySelector('[data-document-path]')?.dataset.canComment === 'true';
const article = document.querySelector('[data-document-path]');
const workspaceShell = article?.closest('.workspace-shell--document');
const article = document.querySelector('[data-document-id]');
if (!article) return;
const documentId = article.dataset.documentId;
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 commentsAsideListMobile = document.querySelector('[data-comments-aside-list-mobile]');
const stripTrack = document.querySelector('[data-comments-strip-track]');
const commentsAside = document.querySelector('[data-comments-aside]');
const commentsSheetToggle = document.querySelector('[data-toggle-comments]');
const commentsSheetClose = document.querySelector('[data-close-comments]');
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
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() {
if (!commentVisibilityKey) return false;
try {
return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
} 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() {
return Boolean(workspaceShell?.classList.contains('comments-visible')) === false;
return !workspaceShell?.classList.contains('comments-visible');
}
function setCommentsHidden(hidden) {
if (article) article.classList.toggle('comments-hidden', hidden);
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
if (commentVisibilityKey) {
try {
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
} catch (err) {
// Ignore persistence failures
}
article.classList.toggle('comments-hidden', hidden);
workspaceShell?.classList.toggle('comments-visible', !hidden);
try {
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
} catch (err) {
// Storage can be unavailable in private browsing contexts.
}
if (commentToggle) {
commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true');
@@ -60,173 +54,218 @@
}
}
if (commentToggle) {
commentToggle.setAttribute('aria-pressed', commentsHidden() ? 'false' : 'true');
commentToggle.addEventListener('click', () => {
setCommentsHidden(!commentsHidden());
});
}
setCommentsHidden(readCommentVisibility());
commentToggle?.addEventListener('click', () => setCommentsHidden(!commentsHidden()));
function hashParagraph(el) {
const text = (el.textContent || '').trim();
if (!text) return null;
let hash = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
hash = ((hash << 5) - hash) + text.charCodeAt(i);
hash &= hash;
}
return 'h' + Math.abs(hash).toString(36);
}
function addCommentButton(el, hash) {
const btn = document.createElement('button');
btn.className = 'comment-anchor-btn';
btn.type = 'button';
btn.title = 'Add comment';
btn.setAttribute('aria-label', 'Add comment');
btn.innerHTML = `
function addCommentButton(el, anchorHash) {
const button = document.createElement('button');
button.className = 'comment-anchor-btn';
button.type = 'button';
button.title = 'Add comment';
button.setAttribute('aria-label', 'Add comment');
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">
<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="M10 10h4"></path>
<path d="M12 8v4"></path><path d="M10 10h4"></path>
</svg>
<span class="comment-anchor-count" aria-hidden="true" hidden></span>
<span class="sr-only">Add comment</span>
`;
btn.addEventListener('click', () => promptComment(el, hash));
el.appendChild(btn);
<span class="sr-only">Add comment</span>`;
button.addEventListener('click', () => createComment(anchorHash));
el.appendChild(button);
}
function updateCommentButtonState(el, count) {
const btn = el.querySelector('.comment-anchor-btn');
if (!btn) return;
const hasComments = count > 0;
btn.classList.toggle('comment-anchor-btn--has-comments', hasComments);
btn.setAttribute('aria-label', hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment');
btn.title = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
const countNode = btn.querySelector('.comment-anchor-count');
if (countNode) {
countNode.hidden = !hasComments;
countNode.textContent = count > 99 ? '99+' : String(count);
function updateCommentButtonState(el, comments) {
const button = el.querySelector('.comment-anchor-btn');
const count = comments.filter(comment => !comment.resolvedAt).length;
if (button) {
const hasComments = count > 0;
button.classList.toggle('comment-anchor-btn--has-comments', hasComments);
const label = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
button.setAttribute('aria-label', label);
button.title = label;
const countNode = button.querySelector('.comment-anchor-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) {
const text = window.prompt('Comment on this paragraph:');
if (!text || !text.trim()) return;
async function createComment(anchorHash, parentId) {
const promptText = parentId ? 'Reply to this comment:' : 'Comment on this section:';
const content = window.prompt(promptText);
if (!content || !content.trim()) return;
try {
const res = await fetch('/api/comments', {
const response = await fetch('/api/comments', {
method: 'POST',
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());
await loadCommentsForAnchor(el, anchorHash);
if (!response.ok) throw new Error(await response.text());
await loadCommentsForAnchor(anchorHash);
} catch (err) {
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 {
const res = await fetch(`/api/comments?document_id=${encodeURIComponent(documentId)}&anchor_hash=${encodeURIComponent(anchorHash)}`);
if (!res.ok) return;
const data = await res.json();
renderComments(el, data.comments || []);
const response = await fetch(`/api/comments/${encodeURIComponent(comment.id)}/resolve`, { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
await loadCommentsForAnchor(anchorHash);
} 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) {
console.error('load comments', err);
}
}
function renderComments(el, comments) {
const existing = el.querySelector('.comment-list');
if (existing) existing.remove();
updateCommentButtonState(el, comments.length);
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
if (!comments.length) {
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);
function buildCommentForest(comments) {
const nodes = new Map(comments.map(comment => [comment.id, { comment, children: [] }]));
const roots = [];
comments.forEach(comment => {
const node = nodes.get(comment.id);
const parent = comment.parentId ? nodes.get(comment.parentId) : null;
if (parent) parent.children.push(node);
else roots.push(node);
});
el.appendChild(list);
renderCommentsAside();
updateStripMarkers();
return roots;
}
function buildAsideItemFor(el) {
const comments = [];
el.querySelectorAll('.comment-item').forEach(item => {
const meta = item.querySelector('.comment-meta');
const bodyEl = item.querySelector('.comment-body');
comments.push({
authorName: meta?.querySelector('strong')?.textContent || 'Anonymous',
createdAt: meta?.querySelector('time')?.textContent || '',
content: bodyEl?.textContent || '',
});
});
function renderThreadNode(node, anchorHash, variant) {
const comment = node.comment;
const wrapper = document.createElement('div');
wrapper.className = `comment-thread${comment.resolvedAt ? ' is-resolved' : ''}`;
wrapper.dataset.commentId = comment.id;
const item = document.createElement('div');
item.className = variant === 'aside' ? 'comments-aside-item__comment' : 'comment-item';
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;
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
const truncated = targetText.length > 120 ? targetText + '…' : targetText;
const asideItem = document.createElement('div');
asideItem.className = 'comments-aside-item';
asideItem.dataset.anchorHash = el.dataset.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);
});
asideItem.dataset.anchorHash = anchorHash;
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;
}
function renderCommentsInto(container) {
if (!container) return;
container.innerHTML = '';
const body = document.querySelector('.markdown-body');
if (!body) return;
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
if (!commentables.length) {
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);
let rendered = 0;
elementsByAnchor.forEach((el, anchorHash) => {
const item = buildAsideItem(anchorHash, el, commentsByAnchor.get(anchorHash) || []);
if (item) {
container.appendChild(item);
rendered++;
}
});
if (!rendered) {
container.innerHTML = `<p class="document-comments-aside__empty">${includeResolved ? 'No comments found.' : 'No active comments yet.'}</p>`;
}
}
function renderCommentsAside() {
@@ -237,177 +276,148 @@
function updateStripMarkers() {
if (!stripTrack) return;
stripTrack.innerHTML = '';
const body = document.querySelector('.markdown-body');
const shell = document.querySelector('.document-shell');
if (!body || !shell) return;
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
const shellRect = shell.getBoundingClientRect();
const trackHeight = stripTrack.clientHeight;
commentables.forEach(el => {
if (!shell) return;
elementsByAnchor.forEach((el, anchorHash) => {
if (!(commentsByAnchor.get(anchorHash) || []).length) return;
const rect = el.getBoundingClientRect();
const shellRect = shell.getBoundingClientRect();
const relativeTop = rect.top - shellRect.top + shell.scrollTop;
const percent = (relativeTop / shell.scrollHeight) * 100;
const clamped = Math.max(0, Math.min(95, percent));
const marker = document.createElement('button');
marker.className = 'document-comments-strip__marker';
marker.type = 'button';
marker.style.top = clamped + '%';
marker.dataset.anchorHash = el.dataset.anchorHash;
marker.style.top = Math.max(0, Math.min(95, percent)) + '%';
marker.dataset.anchorHash = anchorHash;
marker.title = 'Jump to comments';
marker.addEventListener('click', () => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (commentsHidden()) setCommentsHidden(false);
// Highlight the corresponding sidebar item
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);
}
commentsAsideList?.querySelector(`[data-anchor-hash="${anchorHash}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
stripTrack.appendChild(marker);
});
}
let bidirectionalHoverInitialized = false;
function initBidirectionalHover() {
if (bidirectionalHoverInitialized) return;
bidirectionalHoverInitialized = true;
const body = document.querySelector('.markdown-body');
if (!body || !commentsAsideList) return;
// Body → sidebar + marker
body.addEventListener('mouseenter', (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.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);
body.addEventListener('mouseover', event => toggleHighlight(event.target.closest('.commentable'), true));
body.addEventListener('mouseout', event => toggleHighlight(event.target.closest('.commentable'), false));
commentsAsideList.addEventListener('mouseover', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), true));
commentsAsideList.addEventListener('mouseout', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), false));
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
function toggleHighlight(el, active) {
if (!el) return;
const hash = el.dataset.anchorHash;
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) {
const d = new Date(iso);
return d.toLocaleString();
}
function commentsSheetOpen() {
return commentsAside?.classList.contains('is-open');
function toggleAsideHighlight(item, active) {
if (!item) return;
toggleHighlight(elementsByAnchor.get(item.dataset.anchorHash), active);
}
function setCommentsSheetOpen(open) {
if (!commentsAside) return;
commentsAside.classList.toggle('is-open', open);
if (commentsSheetToggle) {
commentsSheetToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
commentsSheetToggle.setAttribute('aria-label', open ? 'Hide comments' : 'Show comments');
}
commentsSheetToggle?.setAttribute('aria-expanded', String(open));
if (open) {
document.body.classList.add('drawer-open');
renderCommentsAside();
} else {
if (!pickerOpen() && !metaOpen()) {
document.body.classList.remove('drawer-open');
}
} else if (!document.querySelector('[data-picker-drawer].is-open, [data-meta-drawer].is-open')) {
document.body.classList.remove('drawer-open');
}
}
function pickerOpen() {
return document.querySelector('[data-picker-drawer]')?.classList.contains('is-open');
commentsSheetToggle?.addEventListener('click', () => setCommentsSheetOpen(!commentsAside?.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() {
return document.querySelector('[data-meta-drawer]')?.classList.contains('is-open');
async function loadWatchStatus() {
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) {
commentsSheetToggle.addEventListener('click', () => {
setCommentsSheetOpen(!commentsSheetOpen());
async function toggleWatch() {
if (!watchButton) return;
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) {
commentsSheetClose.addEventListener('click', () => setCommentsSheetOpen(false));
historyButtons.forEach(button => button.addEventListener('click', async () => {
includeResolved = !includeResolved;
updateHistoryButtons();
await Promise.all(Array.from(elementsByAnchor.keys(), loadCommentsForAnchor));
}));
function formatDate(iso) {
return new Date(iso).toLocaleString();
}
function init() {
const body = document.querySelector('.markdown-body');
if (!body) return;
const paragraphs = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
paragraphs.forEach(el => {
const hash = hashParagraph(el);
if (!hash) return;
el.dataset.anchorHash = hash;
const commentables = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
commentables.forEach(el => {
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim();
const anchorHash = hashParagraph(el);
if (!anchorHash) return;
el.dataset.anchorHash = anchorHash;
el.dataset.commentTarget = targetText.length > 120 ? targetText.slice(0, 120) + '…' : targetText;
el.classList.add('commentable');
if (canComment) addCommentButton(el, hash);
loadCommentsForAnchor(el, hash);
elementsByAnchor.set(anchorHash, el);
if (canComment) addCommentButton(el, anchorHash);
loadCommentsForAnchor(anchorHash);
});
renderCommentsAside();
initBidirectionalHover();
// Update markers on scroll/resize
loadWatchStatus();
updateHistoryButtons();
const shell = document.querySelector('.document-shell');
if (shell) {
shell.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
}
shell?.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers));
// Initial marker update after layout settles
setTimeout(updateStripMarkers, 100);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();

View File

@@ -7,6 +7,7 @@
const trigger = bell.querySelector('.notification-bell__trigger');
const countEl = bell.querySelector('[data-unread-count]');
const dropdown = bell.querySelector('[data-notification-dropdown]');
if (!trigger || !countEl || !dropdown) return;
let isOpen = false;
trigger.addEventListener('click', (e) => {
@@ -76,31 +77,42 @@
notifications.forEach(n => {
const li = document.createElement('li');
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>
<time class="notification-time">${formatDate(n.createdAt)}</time>
`;
if (!n.readAt) {
li.addEventListener('click', () => markRead(n.id));
}
button.addEventListener('click', async () => {
if (!n.readAt) await markRead(n.id, false);
const target = notificationURL(n);
if (target) window.location.assign(target);
});
li.appendChild(button);
list.appendChild(li);
});
// Add "Mark all read" button
const footer = document.createElement('li');
footer.className = 'notification-footer';
const btn = document.createElement('button');
btn.textContent = 'Mark all read';
btn.addEventListener('click', markAllRead);
footer.appendChild(btn);
list.appendChild(footer);
if (notifications.some(notification => !notification.readAt)) {
const footer = document.createElement('li');
footer.className = 'notification-footer';
const btn = document.createElement('button');
btn.textContent = 'Mark all read';
btn.addEventListener('click', markAllRead);
footer.appendChild(btn);
list.appendChild(footer);
}
}
async function markRead(id) {
async function markRead(id, refresh = true) {
try {
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
updateBell();
loadNotifications();
const response = await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
if (refresh) {
updateBell();
loadNotifications();
}
} catch (err) {
console.error('mark read', err);
}
@@ -108,7 +120,8 @@
async function markAllRead() {
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();
loadNotifications();
} catch (err) {
@@ -127,6 +140,12 @@
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
if (window.__cairnquireRealtime) {
window.__cairnquireRealtime.on('notification', () => {

View File

@@ -198,19 +198,27 @@ code {
}
.notification-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
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 {
background: var(--accent-soft);
}
.notification-item.is-read {
opacity: 0.7;
cursor: default;
}
.notification-message {
@@ -1199,6 +1207,27 @@ code {
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 */
.comments-aside-item {
margin-bottom: 0.5rem;
@@ -1216,6 +1245,13 @@ code {
}
.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;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border);
@@ -1259,6 +1295,39 @@ code {
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 {
margin: 0;
padding: 1rem 0;

View File

@@ -107,6 +107,7 @@
<script src="/static/cache.js" defer></script>
<script src="/static/sync.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/auth.js" defer></script>
<script src="/static/render.js" defer></script>

View File

@@ -4,7 +4,7 @@
<section class="workspace-shell workspace-shell--document">
<div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div>
{{ 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">
<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>
@@ -43,6 +43,10 @@
</svg>
</summary>
<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 }}
<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>
@@ -106,6 +110,9 @@
<div class="document-comments-strip__track" data-comments-strip-track></div>
</div>
<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>
@@ -126,6 +133,9 @@
</button>
</div>
<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>
</div>

View File

@@ -13,8 +13,12 @@ services:
CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content"
CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files"
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_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_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}"
volumes: