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. # Required for deployed passkeys and device authorization URLs.
CAIRNQUIRE_PUBLIC_ORIGIN=https://cairnquire.example.com CAIRNQUIRE_PUBLIC_ORIGIN=https://cairnquire.example.com
# Optional SMTP settings. The bundled Compose file defaults to its local # Outbound provider: smtp, postmark, cloudflare, or noop. Compose defaults to
# Mailpit service so notifications can be inspected during initial deployment. # smtp with Mailpit so messages can be inspected during initial deployment.
CAIRNQUIRE_EMAIL_PROVIDER=smtp
CAIRNQUIRE_EMAIL_SMTP_HOST=mailpit CAIRNQUIRE_EMAIL_SMTP_HOST=mailpit
CAIRNQUIRE_EMAIL_SMTP_PORT=1025 CAIRNQUIRE_EMAIL_SMTP_PORT=1025
CAIRNQUIRE_EMAIL_FROM=notifications@cairnquire.local CAIRNQUIRE_EMAIL_FROM=notifications@cairnquire.local
# Postmark production delivery:
# CAIRNQUIRE_EMAIL_PROVIDER=postmark
# CAIRNQUIRE_EMAIL_POSTMARK_TOKEN=server-token
# CAIRNQUIRE_EMAIL_FROM=notifications@example.com
# Cloudflare Email Service production delivery. Enable Email Sending for the
# sender domain first and use an API token with Email Sending permission.
# CAIRNQUIRE_EMAIL_PROVIDER=cloudflare
# CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID=cloudflare-account-id
# CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN=cloudflare-api-token
# CAIRNQUIRE_EMAIL_FROM=notifications@example.com
# Optional: INFO, DEBUG, WARN, or ERROR. # Optional: INFO, DEBUG, WARN, or ERROR.
CAIRNQUIRE_LOG_LEVEL=INFO CAIRNQUIRE_LOG_LEVEL=INFO

View File

@@ -1,64 +1,74 @@
# Project Status Report # Project Status Report
**Generated:** 2026-06-24 **Updated:** 2026-07-22
**Branch:** main **Branch:** main
**Last Commit:** b6d2ded - passkey existing (2026-06-16) **Last Commit:** 09f51d1 - Add queued email notifications for collaboration events
## Quick Stats ## Quick Stats
| Metric | Value | | Metric | Value |
|--------|-------| |--------|-------|
| Total Tracked Files | 189 | | Total Tracked Files | 201 |
| Go Files | 46 | | Go Files | 63 |
| Swift Files | 14 (macOS app) | | Swift Files | 15 (macOS app and package manifest) |
| Test Files | 10 | | Go Test Files | 18 |
| Avg Test Coverage | ~39% (markdown 87%, store 70%, auth 55%, sync 54%) | | Aggregate Go Test Coverage | 52.4% |
| Milestones Complete | 3/6 (50%) | | Milestones Complete | 3/6 (50%) |
## Recent Activity (since 2026-06-01 report) ## Verification
16 commits on `main` between `fb0673a` (2026-06-01) and `b6d2ded` (2026-06-16): - `go test -coverpkg=./... -coverprofile=... ./...` passes with 52.4% aggregate statement coverage.
71 files changed, +7908 / -812 lines. - `swift build` passes for the macOS app and CLI.
- Focused race tests pass for email, collaboration, and HTTP packages. A full `go test -race ./...` exceeds the current five-minute local timeout.
- `govulncheck` currently fails because Go 1.24.2 has 31 reachable standard-library vulnerabilities; the pinned Go toolchain must be upgraded before production sign-off.
- Highest package coverage: Markdown 87.0%, email 77.9%, store 70.0%, config 67.9%, auth 54.9%, and sync 54.2%.
- Collaboration, configuration, email-provider, and collaboration HTTP integration tests are present; database, logging, and realtime still lack direct suites.
Highlights: ## Recent Activity
- `b6d2ded` passkey existing-user login flow
- `4655008` text editor link autocomplete Recent work has moved the project beyond the previous 2026-06-24 report:
- `0adb980` CodeMirror editor integration
- `c39c50b` dropdown menu + comment side panel (wires M4 comment UI) - Durable email queue with retry/backoff and failed-delivery inspection.
- `6c30fd8` sync service update - Postmark outbound sender with API-level tests.
- `04a1f2b` ops design + app scaffolding (`entrypoint.sh`) - Cloudflare Email Service REST sender with retry/permanent-error classification and API-level tests.
- `632621b`/`1e939f3` macOS menu-bar app: live search popover, drop-target icon - File-change and comment notifications now enqueue email delivery.
- `ddc7d5c` accounts and email wiring - Notification bell/list/read UI is active.
- `93e96be`/`73d505a` UI CSS expansion (~1900 lines) and workspace browser polish - Document watch/unwatch controls are active, watcher subscriptions are deduplicated, and ancestor-folder matching is fixed.
- New untracked subsystems: `permissions_handlers.go` (RBAC sharing UI), `tags.js`/`tag.gohtml` (tagging), `keyboard-nav.js`, `mobile.js`, `archive.js` - Nested comment replies, whole-thread resolution, and resolved-history UI are active.
- Notification dispatch rechecks the watcher's current document permission.
- Existing users can add another passkey from their account page.
- Resource permission UI and private-by-default filtering cover documents, folders, navigation, search, API document lists, and native sync snapshots.
- CodeMirror editor, wiki-link autocomplete, keyboard navigation, mobile drawers, archive UI, and tag pages are present.
- The macOS menu-bar app and CLI compile successfully.
## Milestone Progress ## Milestone Progress
- [x] Milestone 1: Foundation - [x] Milestone 1: Foundation
- [x] Milestone 2: Sync Protocol - [x] Milestone 2: Sync Protocol
- [x] Milestone 3: Authentication - [x] Milestone 3: Authentication
- [ ] Milestone 4: Collaboration (comments UI wired; email integration in progress; digest/preferences pending) - [ ] Milestone 4: Collaboration (threaded comments, notification bell, watch controls, and queued Postmark/Cloudflare/SMTP delivery shipped; preferences, digest, inbound replies, mention/conflict triggers, and live-provider tests remain)
- [ ] Milestone 5: Search & UI (server FTS5 done; design-system CSS, keyboard nav, mobile responsive shipped; `/design` route, Flexsearch, a11y audits pending) - [ ] Milestone 5: Search & UI (server FTS5, tags, keyboard navigation, and responsive foundations shipped; offline client search, `/design`, performance, and accessibility validation remain)
- [ ] Milestone 6: Production (not started) - [ ] Milestone 6: Production (health/readiness endpoints, CI, deployment scaffolding, and some integration tests exist; hardening acceptance criteria remain open)
**Current Focus:** M4 Email Integration (Postmark adapter, templates, queue with retry) ## Current Focus
## Untracked Subsystems (not in original plan) Milestone 4 collaboration completion:
These shipped but are not milestone-tracked. Tracked via `notes/untracked-subsystems.md`: - Add notification preferences and digest delivery.
- Add inbound email replies through a verified webhook flow.
- Add mention and conflict notification triggers.
- Test watcher-to-email delivery against live Postmark and Cloudflare accounts.
- **Permissions / RBAC sharing UI** — `permissions_handlers.go` (431 lines), `permissions.gohtml`, `permissions.js`. Per-resource read/write/admin grants. ## Known Gaps Requiring Human Review
- **Tagging** — `tags.js`, `tag.gohtml`. Document tags.
- **CodeMirror inline editor** — `editor.js` (+620), `codemirror.bundle.js`, link autocomplete.
- **macOS menu-bar app** — 14 Swift files, live search popover, drag-drop target. Beyond web-first plan scope.
- **API tokens** — `token_create.gohtml`, `auth.APIKey` types.
- **Password reset flow** — `password_reset.gohtml`, `auth.PasswordResetToken` repository methods.
## Notes for Human Review - Go 1.24.2 is below the patched versions reported by `govulncheck`, including fixes for `html/template`, `crypto/tls`, `net/http`, and `crypto/x509` vulnerabilities.
- WebSocket broadcasts are authenticated but not filtered by per-document permissions.
- `collaboration.Service` defines its own `EmailSender` interface duplicating `email.Sender`. Consolidate when convenient. - Content-hash downloads are not tied back to effective document permission.
- `Service.notifyWatchersOfComment` and `NotifyDocumentChanged` create in-app notifications only; never invoke `s.email`. `Notification.EmailedAt` field unused. - Browser caches are not cleared on logout.
- `email.SMTPSender` uses unauthenticated SMTP (fine for local Mailpit, not Postmark). - Raw HTML is enabled in authored Markdown while the security specification says it should be disabled.
- Folder/collection permission administration is only fully discoverable for documents.
- The implementation uses JSON for SimpleSync while protocol documentation still leaves JSON versus protobuf unresolved.
- Backup documentation does not match the named data volume in Compose.
--- ---
*This report updated 2026-06-24 by hand. Next refresh when M4 email work lands.* *This report is maintained from the implementation and verification results, not milestone checkbox state alone.*

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. The platform needs to send email notifications for: file changes (to watchers), new comments, conflict alerts, and digest summaries. It must also receive email replies and convert them to comments. Requirement: use Postmark email gateway with plain-text only.
## Decision ## Decision
**Postmark HTTP API for outgoing email, Postmark inbound webhook for incoming email.** **Postmark or Cloudflare Email Service HTTP API for outgoing email, with a
provider-specific verified inbound webhook for incoming email.**
Amended 2026-07-22: Cloudflare Email Service is supported as an alternative
transactional outbound provider. The same durable queue and plain-text
templates are shared by Postmark, Cloudflare, and local SMTP. Cloudflare-hosted
Workers would normally use an Email binding, but Cairnquire is a standalone Go
application and therefore uses the authenticated REST API.
### Implementation ### Implementation

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 - Client syncs index to IndexedDB for offline search
- Index rebuilds incrementally on file changes - Index rebuilds incrementally on file changes
### 7. Email (Postmark) ### 7. Email (Postmark or Cloudflare Email Service)
- Go adapter using Postmark HTTP API - Go adapters use the Postmark or Cloudflare Email Sending REST API
- Local development uses SMTP with Mailpit
- A durable SQLite queue handles asynchronous delivery and bounded retries
- Plain-text emails only (no HTML) - Plain-text emails only (no HTML)
- Threading via Message-ID / In-Reply-To headers - Planned: threading via Message-ID / In-Reply-To headers
- Webhook endpoint receives email replies, converts to comments - Planned: verified inbound webhook converts email replies to comments
- Templates: notification, digest, conflict alert - Implemented templates: file change, comment, mention, and conflict; digest remains planned
### 8. File Store (Content-Addressed) ### 8. File Store (Content-Addressed)
- Files stored by SHA-256 hash: `store/ab/cd/abcdef1234...` - Files stored by SHA-256 hash: `store/ab/cd/abcdef1234...`

View File

@@ -9,9 +9,9 @@
- [x] Comment system (backend + UI wired) - [x] Comment system (backend + UI wired)
- [x] Comment creation endpoint (web) - [x] Comment creation endpoint (web)
- [x] Comment threading (parent/child) - schema supports it - [x] Comment threading (parent/child) with nested browser replies
- [x] Line/section anchoring (hash-based) - [x] Line/section anchoring (hash-based)
- [x] Comment resolution (mark as resolved) - [x] Whole-thread resolution with resolved-history view
- [x] Comment display in the Go-served browser UI (`c39c50b` dropdown menu + comment side panel, `comments.js` +330 lines) - [x] Comment display in the Go-served browser UI (`c39c50b` dropdown menu + comment side panel, `comments.js` +330 lines)
- [x] Notification system (backend complete) - [x] Notification system (backend complete)
@@ -21,10 +21,10 @@
- [ ] Global notification settings - [ ] Global notification settings
- [ ] Digest mode (hourly/daily batches) - [ ] Digest mode (hourly/daily batches)
- [x] Web notification UI (backend complete, JS partial) - [x] Web notification UI
- [x] Notification bell with unread count - API exists - [x] Notification bell with unread count
- [x] Notification list dropdown - API exists - [x] Notification list dropdown
- [x] Mark as read functionality - API exists - [x] Mark one/all as read functionality
- [ ] Notification preferences page - [ ] Notification preferences page
### Week 8: Email Integration & Conflict Resolution ### Week 8: Email Integration & Conflict Resolution
@@ -34,6 +34,7 @@
- [x] Postmark adapter (`email/postmark.go`) - [x] Postmark adapter (`email/postmark.go`)
- [x] Plain-text email templates (`email/templates.go`) - [x] Plain-text email templates (`email/templates.go`)
- [x] Outgoing email queue with retry (`email/queue.go` + migration 000016) - [x] Outgoing email queue with retry (`email/queue.go` + migration 000016)
- [x] Cloudflare Email Service REST adapter (`email/cloudflare.go`)
- [ ] Webhook endpoint for inbound email - [ ] Webhook endpoint for inbound email
- [x] Email notification types - [x] Email notification types
@@ -60,20 +61,20 @@
### Functional ### Functional
- [x] Users can add comments to specific lines in a document (API exists) - [x] Users can add comments to specific lines in a document (API exists)
- [x] Comments are linked to document version (hash) - [x] Comments are linked to document version (hash)
- [x] Comment threading works (reply to reply) (schema supports it) - [x] Comment threading works (reply to reply) in the browser and API
- [x] Users can watch files or folders for changes (API exists) - [x] Users can watch files or folders for changes (document UI + APIs)
- [x] Email notifications sent within 1 minute of event (Postmark adapter + queue with retry) - [x] Email notifications queued within 1 minute of event (Postmark, Cloudflare, or SMTP sender)
- [ ] Replying to notification email creates a comment - [ ] Replying to notification email creates a comment
- [ ] Digest emails batch notifications by time window - [ ] Digest emails batch notifications by time window
- [x] Conflicts display side-by-side diff (M2 sync) - [x] Conflicts display a unified diff (M2 sync)
- [x] Users can resolve conflicts via web UI (M2 sync) - [x] Users can resolve conflicts via web UI (M2 sync)
- [ ] Resolved comments are hidden but accessible in history - [x] Resolved comments are hidden but accessible in history
### Non-Functional ### Non-Functional
- [x] Emails are plain-text only (no HTML) — enforced by templates, verified by test - [x] Emails are plain-text only (no HTML) — enforced by templates, verified by test
- [ ] Email threading works in Gmail, Outlook, Apple Mail - [ ] Email threading works in Gmail, Outlook, Apple Mail
- [ ] Webhook signature verified for inbound email - [ ] Webhook signature verified for inbound email
- [x] Failed emails retried 3 times with exponential backoff — queue.go, verified by test - [x] Temporary email failures retried 3 times with exponential backoff; permanent provider rejections fail immediately
- [x] Email queue doesn't block web requests (async processing) — worker runs in goroutine - [x] Email queue doesn't block web requests (async processing) — worker runs in goroutine
### Email Template Examples ### Email Template Examples
@@ -166,7 +167,7 @@ CREATE TABLE watchers (
- [ ] All acceptance criteria pass - [ ] All acceptance criteria pass
- [ ] Email flows tested with real Postmark account - [ ] Email flows tested with real Postmark account
- [ ] Comment threading tested with 3+ levels - [x] Comment threading tested with 3+ levels
- [ ] Conflict resolution tested with 2+ concurrent editors - [ ] Conflict resolution tested with 2+ concurrent editors
- [ ] Notification preferences persist across sessions - [ ] Notification preferences persist across sessions
- [ ] Digest mode tested with 24-hour window - [ ] Digest mode tested with 24-hour window

View File

@@ -150,19 +150,19 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
**Goal:** Comments, notifications, and conflict resolution **Goal:** Comments, notifications, and conflict resolution
- [x] Comment system linked to file versions (by hash) - backend complete - [x] Comment system linked to file versions (by hash) - backend complete
- [ ] Email notifications via Postmark adapter - SMTP stub exists - [x] Queued email notifications via Postmark, Cloudflare Email Service, or local SMTP
- [x] Web-based conflict resolution UI (Milestone 2) - [x] Web-based conflict resolution UI (Milestone 2)
- [ ] Notification preferences (per-file, per-folder, global, digest) - [ ] Notification preferences (per-file, per-folder, global, digest)
- [x] Comment threading schema - UI needs wiring - [x] Comment threading schema and browser reply UI
- [ ] Email replies create comments - [ ] Email replies create comments
**Acceptance Criteria:** **Acceptance Criteria:**
- [x] Users can comment on specific lines/sections of a document (API) - [x] Users can comment on specific lines/sections of a document (API)
- [x] Comments persist and are linked to document versions - [x] Comments persist and are linked to document versions
- [ ] Email notifications sent for watched file changes (NoOp sender) - [x] Email notifications queued for watched file changes with retry
- [ ] Replying to notification email creates a comment - [ ] Replying to notification email creates a comment
- [x] Conflicts display side-by-side diff with resolution actions (M2) - [x] Conflicts display a unified diff with resolution actions (M2)
- [ ] Digest mode batches notifications by time window - [ ] Digest mode batches notifications by time window
--- ---
@@ -265,7 +265,7 @@ See [architecture-overview.md](architecture-overview.md) for detailed component
| Markdown | `github.com/yuin/goldmark` + extensions | Fast, extensible, CommonMark compliant | | Markdown | `github.com/yuin/goldmark` + extensions | Fast, extensible, CommonMark compliant |
| WebSocket | `github.com/gorilla/websocket` | Most widely used Go WS library | | WebSocket | `github.com/gorilla/websocket` | Most widely used Go WS library |
| File Watch | `github.com/fsnotify/fsnotify` | Cross-platform, stdlib-adjacent | | File Watch | `github.com/fsnotify/fsnotify` | Cross-platform, stdlib-adjacent |
| Email | `github.com/keighl/postmark` + `net/smtp` | Postmark adapter + SMTP fallback | | Email | `net/http` + `net/smtp` | Postmark/Cloudflare REST adapters + local SMTP |
| Crypto | `crypto/ed25519`, `crypto/sha256` | Standard library, no external deps | | Crypto | `crypto/ed25519`, `crypto/sha256` | Standard library, no external deps |
| Templates | `html/template` | Stdlib, auto-escaping, SSR-safe | | Templates | `html/template` | Stdlib, auto-escaping, SSR-safe |
@@ -371,7 +371,7 @@ cairnquire/
| WebAuthn/passkey adoption issues | Medium | Medium | Password fallback always available; extensive browser testing | | WebAuthn/passkey adoption issues | Medium | Medium | Password fallback always available; extensive browser testing |
| SQLite performance at scale | Low | High | libsql replication path; FTS5 for search; benchmark early | | SQLite performance at scale | Low | High | libsql replication path; FTS5 for search; benchmark early |
| Markdown extension parser bugs | Medium | Medium | Fuzz testing; strict CommonMark base; gradual feature rollout | | Markdown extension parser bugs | Medium | Medium | Fuzz testing; strict CommonMark base; gradual feature rollout |
| Email deliverability | Medium | Medium | Postmark reputation monitoring; SPF/DKIM setup; SMTP fallback | | Email deliverability | Medium | Medium | Provider reputation monitoring; SPF/DKIM/DMARC setup; provider fallback |
| Frontend bundle bloat | Medium | Medium | Bundle analysis in CI; tree-shaking enforced; lazy loading | | Frontend bundle bloat | Medium | Medium | Bundle analysis in CI; tree-shaking enforced; lazy loading |
| File sync conflicts in teams | High | Medium | Clear UX for conflict resolution; email notification; automatic merge when possible | | File sync conflicts in teams | High | Medium | Clear UX for conflict resolution; email notification; automatic merge when possible |
@@ -383,11 +383,9 @@ cairnquire/
2. Complete Milestone 2: Sync Protocol 2. Complete Milestone 2: Sync Protocol
3. Complete Milestone 3: Authentication 3. Complete Milestone 3: Authentication
4. 🔄 Finish Milestone 4: Collaboration 4. 🔄 Finish Milestone 4: Collaboration
- Wire up comment UI in browser (JS exists, needs integration) - Complete inbound email replies, mention notifications, and conflict notifications
- Wire up notification bell UI - Add global/per-resource notification preferences and digest scheduling
- Implement actual email sending (Postmark or SMTP) - Test watcher-driven notifications against live Postmark and Cloudflare accounts
- Add notification preferences page
- Test watcher-driven notifications end-to-end
5. 🔄 Finish Milestone 5: Search & UI Polish 5. 🔄 Finish Milestone 5: Search & UI Polish
- Tag filtering in search - Tag filtering in search
- Design system /design route - Design system /design route
@@ -395,11 +393,11 @@ cairnquire/
- Mobile-responsive layout polish - Mobile-responsive layout polish
- Accessibility improvements - Accessibility improvements
6. Milestone 6: Production Hardening 6. Milestone 6: Production Hardening
- Increase test coverage from 39% to 80%+ - Increase test coverage from 52.4% to 80%+
- Add integration tests for auth, sync, collaboration - Add integration tests for auth, sync, collaboration
- Security audit checklist - Security audit checklist
- Backup/restore scripts - Backup/restore scripts
- Health check and monitoring endpoints - Complete dependency health checks and add monitoring endpoints
--- ---

View File

@@ -53,8 +53,12 @@ deployment, only `CAIRNQUIRE_PUBLIC_ORIGIN` must be supplied in `.env`.
| Variable | Required | Compose default | Description | | Variable | Required | Compose default | Description |
|----------|----------|-----------------|-------------| |----------|----------|-----------------|-------------|
| `CAIRNQUIRE_PUBLIC_ORIGIN` | Yes | None | Exact external HTTPS origin. Passkeys and device-flow URLs depend on this value. | | `CAIRNQUIRE_PUBLIC_ORIGIN` | Yes | None | Exact external HTTPS origin. Passkeys and device-flow URLs depend on this value. |
| `CAIRNQUIRE_EMAIL_PROVIDER` | No | `smtp` | Outbound provider: `smtp`, `postmark`, `cloudflare`, or `noop`. |
| `CAIRNQUIRE_EMAIL_SMTP_HOST` | No | `mailpit` | SMTP server used for notifications. | | `CAIRNQUIRE_EMAIL_SMTP_HOST` | No | `mailpit` | SMTP server used for notifications. |
| `CAIRNQUIRE_EMAIL_SMTP_PORT` | No | `1025` | SMTP server port. | | `CAIRNQUIRE_EMAIL_SMTP_PORT` | No | `1025` | SMTP server port. |
| `CAIRNQUIRE_EMAIL_POSTMARK_TOKEN` | For Postmark | Empty | Postmark server token. |
| `CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID` | For Cloudflare | Empty | Account with Cloudflare Email Sending enabled. |
| `CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN` | For Cloudflare | Empty | API token with Email Sending permission. Store it as a deployment secret. |
| `CAIRNQUIRE_EMAIL_FROM` | No | `notifications@cairnquire.local` | Notification sender address. | | `CAIRNQUIRE_EMAIL_FROM` | No | `notifications@cairnquire.local` | Notification sender address. |
| `CAIRNQUIRE_LOG_LEVEL` | No | `INFO` | Application log level. | | `CAIRNQUIRE_LOG_LEVEL` | No | `INFO` | Application log level. |
@@ -72,6 +76,23 @@ checked-in Compose deployment:
| `CAIRNQUIRE_CONFIG` | Empty | Optional JSON configuration file. Environment variables override it. | | `CAIRNQUIRE_CONFIG` | Empty | Optional JSON configuration file. Environment variables override it. |
| `CAIRNQUIRE_DEV_MODE` | `false` | Local-only auth shortcut. Never enable this on a deployed server. | | `CAIRNQUIRE_DEV_MODE` | `false` | Local-only auth shortcut. Never enable this on a deployed server. |
### Cloudflare Email Service
Cairnquire is an external Go application, so it uses Cloudflare Email
Service's REST API rather than a Workers binding. Before enabling the provider:
1. Enable Email Sending for the sender domain in Cloudflare, or run `npx wrangler email sending enable example.com`.
2. Create a Cloudflare API token with Email Sending permission for the account.
3. Set `CAIRNQUIRE_EMAIL_PROVIDER=cloudflare`, the account ID, API token, and a `CAIRNQUIRE_EMAIL_FROM` address on the onboarded domain.
Cloudflare Email Service is used only for transactional notifications. API
tokens must be supplied through deployment secrets and must not be committed.
The durable queue retries Cloudflare `429` and server errors; validation and
permanent-bounce responses are retained immediately as failed deliveries.
Cloudflare Email Routing for inbound comment replies is a separate Milestone 4
feature and is not enabled by these outbound settings.
## Persistent Data ## Persistent Data
Compose mounts: Compose mounts:

View File

@@ -116,8 +116,12 @@ Supported variables:
- `CAIRNQUIRE_DATABASE_AUTH_TOKEN` - Optional auth token for the remote libsql primary. - `CAIRNQUIRE_DATABASE_AUTH_TOKEN` - Optional auth token for the remote libsql primary.
- `CAIRNQUIRE_CONTENT_SOURCE_DIR` - Editable Markdown source directory. - `CAIRNQUIRE_CONTENT_SOURCE_DIR` - Editable Markdown source directory.
- `CAIRNQUIRE_CONTENT_STORE_DIR` - Content-addressed attachment store. - `CAIRNQUIRE_CONTENT_STORE_DIR` - Content-addressed attachment store.
- `CAIRNQUIRE_EMAIL_SMTP_HOST` - SMTP host. Set to an empty string in JSON config to disable delivery. - `CAIRNQUIRE_EMAIL_PROVIDER` - `smtp`, `postmark`, `cloudflare`, or `noop`. Compose defaults to `smtp` with Mailpit.
- `CAIRNQUIRE_EMAIL_SMTP_HOST` - SMTP host for local delivery.
- `CAIRNQUIRE_EMAIL_SMTP_PORT` - SMTP port. Defaults to `1025`. - `CAIRNQUIRE_EMAIL_SMTP_PORT` - SMTP port. Defaults to `1025`.
- `CAIRNQUIRE_EMAIL_POSTMARK_TOKEN` - Postmark server token when the provider is `postmark`.
- `CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID` - Cloudflare account with Email Sending enabled.
- `CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN` - Cloudflare token with Email Sending permission.
- `CAIRNQUIRE_EMAIL_FROM` - Notification sender address. - `CAIRNQUIRE_EMAIL_FROM` - Notification sender address.
- `CAIRNQUIRE_LOG_LEVEL` - Defaults to `INFO`. - `CAIRNQUIRE_LOG_LEVEL` - Defaults to `INFO`.
- `CAIRNQUIRE_DEV_MODE` - Optional local-only auth shortcut. Do not enable on a deployed server. - `CAIRNQUIRE_DEV_MODE` - Optional local-only auth shortcut. Do not enable on a deployed server.

View File

@@ -73,6 +73,16 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
var emailQueue *email.Queue var emailQueue *email.Queue
emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName} emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName}
switch cfg.Email.ResolvedProvider() { switch cfg.Email.ResolvedProvider() {
case "cloudflare":
cf := email.NewCloudflareSender(
cfg.Email.CloudflareAccountID,
cfg.Email.CloudflareAPIToken,
cfg.Email.From,
cfg.Email.CloudflareAPIURL,
logger,
)
emailSender = cf
authService.SetEmailSender(cf)
case "postmark": case "postmark":
pm := email.NewPostmarkSender(cfg.Email.PostmarkToken, cfg.Email.From, cfg.Email.PostmarkAPIURL, logger) pm := email.NewPostmarkSender(cfg.Email.PostmarkToken, cfg.Email.From, cfg.Email.PostmarkAPIURL, logger)
emailSender = pm emailSender = pm
@@ -95,7 +105,22 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
EmailQueue: emailQueue, EmailQueue: emailQueue,
EmailRenderer: emailRenderer, EmailRenderer: emailRenderer,
UserLookup: authRepo, UserLookup: authRepo,
PublicOrigin: cfg.Auth.PublicOrigin, CanReadDocument: func(ctx context.Context, userID, documentID string) (bool, error) {
document, err := repo.GetDocumentByID(ctx, documentID)
if err != nil {
return false, err
}
principal, err := authService.PrincipalForUser(ctx, userID)
if err != nil {
return false, err
}
permission, err := authService.EffectiveResourcePermission(ctx, principal, auth.ResourceDocument, document.Path, document.Tags)
if err != nil {
return false, err
}
return auth.PermissionAllows(permission, auth.PermissionRead), nil
},
PublicOrigin: cfg.Auth.PublicOrigin,
}) })
service.OnChange(func(change docs.DocumentChange) { service.OnChange(func(change docs.DocumentChange) {

View File

@@ -63,14 +63,17 @@ func (r *Repository) GetComment(ctx context.Context, id string) (*Comment, error
return &c, nil return &c, nil
} }
func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string) ([]Comment, error) { func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) {
rows, err := r.db.QueryContext(ctx, ` query := `
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
FROM comments c FROM comments c
JOIN users u ON u.id = c.author_id JOIN users u ON u.id = c.author_id
WHERE c.document_id = ? WHERE c.document_id = ?`
ORDER BY c.created_at ASC if !includeResolved {
`, documentID) query += ` AND c.resolved_at IS NULL`
}
query += ` ORDER BY c.created_at ASC`
rows, err := r.db.QueryContext(ctx, query, documentID)
if err != nil { if err != nil {
return nil, fmt.Errorf("list comments: %w", err) return nil, fmt.Errorf("list comments: %w", err)
} }
@@ -79,14 +82,17 @@ func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID stri
return scanComments(rows) return scanComments(rows)
} }
func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) { func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) {
rows, err := r.db.QueryContext(ctx, ` query := `
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
FROM comments c FROM comments c
JOIN users u ON u.id = c.author_id JOIN users u ON u.id = c.author_id
WHERE c.document_id = ? AND c.anchor_hash = ? AND c.resolved_at IS NULL WHERE c.document_id = ? AND c.anchor_hash = ?`
ORDER BY c.created_at ASC if !includeResolved {
`, documentID, anchorHash) query += ` AND c.resolved_at IS NULL`
}
query += ` ORDER BY c.created_at ASC`
rows, err := r.db.QueryContext(ctx, query, documentID, anchorHash)
if err != nil { if err != nil {
return nil, fmt.Errorf("list comments by anchor: %w", err) return nil, fmt.Errorf("list comments by anchor: %w", err)
} }
@@ -95,13 +101,25 @@ func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string
return scanComments(rows) return scanComments(rows)
} }
func (r *Repository) ResolveComment(ctx context.Context, commentID string, userID string) error { func (r *Repository) ResolveCommentThread(ctx context.Context, commentID string, userID string) error {
_, err := r.db.ExecContext(ctx, ` result, err := r.db.ExecContext(ctx, `
WITH RECURSIVE thread(id) AS (
SELECT id FROM comments WHERE id = ? AND parent_id IS NULL
UNION ALL
SELECT c.id FROM comments c JOIN thread t ON c.parent_id = t.id
)
UPDATE comments SET resolved_at = ?, resolved_by = ? UPDATE comments SET resolved_at = ?, resolved_by = ?
WHERE id = ? WHERE id IN (SELECT id FROM thread) AND resolved_at IS NULL
`, formatTime(time.Now().UTC()), userID, commentID) `, commentID, formatTime(time.Now().UTC()), userID)
if err != nil { if err != nil {
return fmt.Errorf("resolve comment: %w", err) return fmt.Errorf("resolve comment thread: %w", err)
}
changed, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("resolve comment thread result: %w", err)
}
if changed == 0 {
return fmt.Errorf("comment thread not found or already resolved")
} }
return nil return nil
} }
@@ -228,10 +246,12 @@ func (r *Repository) MarkAllNotificationsRead(ctx context.Context, userID string
// Watchers // Watchers
func (r *Repository) AddWatcher(ctx context.Context, w Watcher) error { func (r *Repository) AddWatcher(ctx context.Context, w Watcher) error {
if (w.DocumentID == nil) == (w.FolderPath == nil) {
return fmt.Errorf("watcher requires exactly one target")
}
_, err := r.db.ExecContext(ctx, ` _, err := r.db.ExecContext(ctx, `
INSERT INTO watchers (user_id, document_id, folder_path, created_at) INSERT OR IGNORE INTO watchers (user_id, document_id, folder_path, created_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, document_id, folder_path) DO NOTHING
`, w.UserID, w.DocumentID, w.FolderPath, formatTime(w.CreatedAt)) `, w.UserID, w.DocumentID, w.FolderPath, formatTime(w.CreatedAt))
if err != nil { if err != nil {
return fmt.Errorf("add watcher: %w", err) return fmt.Errorf("add watcher: %w", err)
@@ -262,12 +282,12 @@ func (r *Repository) ListWatchersByDocument(ctx context.Context, documentID stri
return scanWatchers(rows) return scanWatchers(rows)
} }
func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath string) ([]Watcher, error) { func (r *Repository) ListWatchersByFolder(ctx context.Context, documentPath string) ([]Watcher, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT user_id, document_id, folder_path, created_at SELECT user_id, document_id, folder_path, created_at
FROM watchers FROM watchers
WHERE folder_path = ? OR ? || '/' = substr(folder_path, 1, length(?) + 1) WHERE folder_path = '' OR folder_path || '/' = substr(?, 1, length(folder_path) + 1)
`, folderPath, folderPath, folderPath) `, documentPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("list folder watchers: %w", err) return nil, fmt.Errorf("list folder watchers: %w", err)
} }
@@ -275,6 +295,24 @@ func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath string
return scanWatchers(rows) return scanWatchers(rows)
} }
func (r *Repository) ListWatchersForDocument(ctx context.Context, documentID string) ([]Watcher, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT DISTINCT w.user_id, w.document_id, w.folder_path, w.created_at
FROM watchers w
JOIN documents d ON d.id = ?
WHERE w.document_id = d.id
OR (w.folder_path IS NOT NULL AND (
w.folder_path = ''
OR w.folder_path || '/' = substr(d.path, 1, length(w.folder_path) + 1)
))
`, documentID)
if err != nil {
return nil, fmt.Errorf("list watchers for document: %w", err)
}
defer rows.Close()
return scanWatchers(rows)
}
func (r *Repository) IsWatching(ctx context.Context, userID string, documentID string) (bool, error) { func (r *Repository) IsWatching(ctx context.Context, userID string, documentID string) (bool, error) {
var count int var count int
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `

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" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"net/url"
"strings"
"time" "time"
"github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/auth"
@@ -50,6 +52,8 @@ type UserLookup interface {
GetUserByID(ctx context.Context, userID string) (auth.User, error) GetUserByID(ctx context.Context, userID string) (auth.User, error)
} }
type DocumentReadAuthorizer func(ctx context.Context, userID, documentID string) (bool, error)
type NoOpEmailSender struct{} type NoOpEmailSender struct{}
func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error { func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
@@ -58,22 +62,24 @@ func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body s
} }
type Service struct { type Service struct {
repo *Repository repo *Repository
email EmailSender email EmailSender
queue EmailQueue queue EmailQueue
renderer EmailRenderer renderer EmailRenderer
users UserLookup users UserLookup
hub *realtime.Hub canReadDocument DocumentReadAuthorizer
logger *slog.Logger hub *realtime.Hub
publicOrigin string logger *slog.Logger
publicOrigin string
} }
type Options struct { type Options struct {
EmailSender EmailSender EmailSender EmailSender
EmailQueue EmailQueue EmailQueue EmailQueue
EmailRenderer EmailRenderer EmailRenderer EmailRenderer
UserLookup UserLookup UserLookup UserLookup
PublicOrigin string CanReadDocument DocumentReadAuthorizer
PublicOrigin string
} }
func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service { func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
@@ -89,14 +95,15 @@ func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Log
opts.EmailSender = &NoOpEmailSender{} opts.EmailSender = &NoOpEmailSender{}
} }
return &Service{ return &Service{
repo: repo, repo: repo,
email: opts.EmailSender, email: opts.EmailSender,
queue: opts.EmailQueue, queue: opts.EmailQueue,
renderer: opts.EmailRenderer, renderer: opts.EmailRenderer,
users: opts.UserLookup, users: opts.UserLookup,
hub: hub, canReadDocument: opts.CanReadDocument,
logger: logger, hub: hub,
publicOrigin: opts.PublicOrigin, logger: logger,
publicOrigin: opts.PublicOrigin,
} }
} }
@@ -106,12 +113,35 @@ func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, i
if principal.UserID == "" { if principal.UserID == "" {
return nil, fmt.Errorf("authentication required") return nil, fmt.Errorf("authentication required")
} }
input.Content = strings.TrimSpace(input.Content)
if input.Content == "" { if input.Content == "" {
return nil, fmt.Errorf("content is required") return nil, fmt.Errorf("content is required")
} }
if input.DocumentID == "" { if input.DocumentID == "" {
return nil, fmt.Errorf("document_id is required") return nil, fmt.Errorf("document_id is required")
} }
if input.VersionHash == "" {
return nil, fmt.Errorf("version_hash is required")
}
if input.ParentID != nil && *input.ParentID != "" {
parent, err := s.repo.GetComment(ctx, *input.ParentID)
if err != nil {
return nil, fmt.Errorf("parent comment not found")
}
if parent.DocumentID != input.DocumentID {
return nil, fmt.Errorf("parent comment belongs to another document")
}
if parent.ResolvedAt != nil {
return nil, fmt.Errorf("cannot reply to a resolved thread")
}
if input.AnchorHash == nil {
input.AnchorHash = parent.AnchorHash
}
if input.AnchorLine == nil {
input.AnchorLine = parent.AnchorLine
}
}
comment := Comment{ comment := Comment{
ID: randomID("comment"), ID: randomID("comment"),
@@ -135,23 +165,27 @@ func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, i
return nil, err return nil, err
} }
// Create notifications for watchers created, err := s.repo.GetComment(ctx, comment.ID)
if err := s.notifyWatchersOfComment(ctx, comment); err != nil { if err != nil {
return nil, err
}
if err := s.notifyWatchersOfComment(ctx, *created); err != nil {
s.logger.Warn("failed to notify watchers of comment", "error", err) s.logger.Warn("failed to notify watchers of comment", "error", err)
} }
// Broadcast to connected clients // Broadcast to connected clients
s.broadcastNotification(principal.UserID) s.broadcastNotification(principal.UserID)
return s.repo.GetComment(ctx, comment.ID) return created, nil
} }
func (s *Service) ListComments(ctx context.Context, documentID string) ([]Comment, error) { func (s *Service) ListComments(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) {
return s.repo.ListCommentsByDocument(ctx, documentID) return s.repo.ListCommentsByDocument(ctx, documentID, includeResolved)
} }
func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) { func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) {
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash) return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash, includeResolved)
} }
func (s *Service) GetComment(ctx context.Context, commentID string) (*Comment, error) { func (s *Service) GetComment(ctx context.Context, commentID string) (*Comment, error) {
@@ -162,7 +196,14 @@ func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal,
if principal.UserID == "" { if principal.UserID == "" {
return fmt.Errorf("authentication required") return fmt.Errorf("authentication required")
} }
return s.repo.ResolveComment(ctx, commentID, principal.UserID) comment, err := s.repo.GetComment(ctx, commentID)
if err != nil {
return fmt.Errorf("comment not found")
}
if comment.ParentID != nil {
return fmt.Errorf("only a thread root can be resolved")
}
return s.repo.ResolveCommentThread(ctx, commentID, principal.UserID)
} }
// Notifications // Notifications
@@ -212,6 +253,9 @@ func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, d
if principal.UserID == "" { if principal.UserID == "" {
return fmt.Errorf("authentication required") return fmt.Errorf("authentication required")
} }
if strings.TrimSpace(documentID) == "" {
return fmt.Errorf("document_id is required")
}
return s.repo.AddWatcher(ctx, Watcher{ return s.repo.AddWatcher(ctx, Watcher{
UserID: principal.UserID, UserID: principal.UserID,
DocumentID: &documentID, DocumentID: &documentID,
@@ -223,6 +267,9 @@ func (s *Service) UnwatchDocument(ctx context.Context, principal auth.Principal,
if principal.UserID == "" { if principal.UserID == "" {
return fmt.Errorf("authentication required") return fmt.Errorf("authentication required")
} }
if strings.TrimSpace(documentID) == "" {
return fmt.Errorf("document_id is required")
}
return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil) return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil)
} }
@@ -230,6 +277,10 @@ func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, fol
if principal.UserID == "" { if principal.UserID == "" {
return fmt.Errorf("authentication required") return fmt.Errorf("authentication required")
} }
folderPath, err := NormalizeFolderPath(folderPath)
if err != nil {
return err
}
return s.repo.AddWatcher(ctx, Watcher{ return s.repo.AddWatcher(ctx, Watcher{
UserID: principal.UserID, UserID: principal.UserID,
FolderPath: &folderPath, FolderPath: &folderPath,
@@ -241,6 +292,10 @@ func (s *Service) UnwatchFolder(ctx context.Context, principal auth.Principal, f
if principal.UserID == "" { if principal.UserID == "" {
return fmt.Errorf("authentication required") return fmt.Errorf("authentication required")
} }
folderPath, err := NormalizeFolderPath(folderPath)
if err != nil {
return err
}
return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath) return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath)
} }
@@ -269,6 +324,9 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
continue continue
} }
seen[w.UserID] = true seen[w.UserID] = true
if !s.watcherCanRead(ctx, w.UserID, documentID) {
continue
}
msg := fmt.Sprintf("%s updated %s", actorName, documentPath) msg := fmt.Sprintf("%s updated %s", actorName, documentPath)
notification := Notification{ notification := Notification{
@@ -298,15 +356,23 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
} }
func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error { func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error {
watchers, err := s.repo.ListWatchersByDocument(ctx, comment.DocumentID) watchers, err := s.repo.ListWatchersForDocument(ctx, comment.DocumentID)
if err != nil { if err != nil {
return err return err
} }
seen := make(map[string]bool)
for _, w := range watchers { for _, w := range watchers {
if w.UserID == comment.AuthorID { if w.UserID == comment.AuthorID {
continue continue
} }
if seen[w.UserID] {
continue
}
seen[w.UserID] = true
if !s.watcherCanRead(ctx, w.UserID, comment.DocumentID) {
continue
}
msg := fmt.Sprintf("New comment on document") msg := fmt.Sprintf("New comment on document")
if comment.AnchorHash != nil { if comment.AnchorHash != nil {
msg = fmt.Sprintf("New comment on a section of the document") msg = fmt.Sprintf("New comment on a section of the document")
@@ -325,9 +391,10 @@ func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment)
continue continue
} }
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{ s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{
ActorName: comment.AuthorName, ActorName: comment.AuthorName,
CommentBody: comment.Content, DocumentPath: documentPathFromID(comment.DocumentID) + ".md",
ViewURL: s.documentURL(comment.DocumentID), CommentBody: comment.Content,
ViewURL: s.documentURL(comment.DocumentID),
}) })
} }
return nil return nil
@@ -349,7 +416,7 @@ func (s *Service) enqueueNotificationEmail(ctx context.Context, notificationID,
return return
} }
if data.ActorName == "" { if data.ActorName == "" {
data.ActorName = user.DisplayName data.ActorName = "Someone"
} }
subject, body, err := s.renderer.Render(kind, data) subject, body, err := s.renderer.Render(kind, data)
if err != nil { if err != nil {
@@ -365,7 +432,25 @@ func (s *Service) documentURL(documentPath string) string {
if s.publicOrigin == "" { if s.publicOrigin == "" {
return "" return ""
} }
return s.publicOrigin + "/" + documentPath clean := strings.TrimPrefix(documentPath, "doc:")
clean = strings.TrimSuffix(strings.TrimPrefix(clean, "/"), ".md")
parts := strings.Split(clean, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.TrimRight(s.publicOrigin, "/") + "/docs/" + strings.Join(parts, "/")
}
func (s *Service) watcherCanRead(ctx context.Context, userID, documentID string) bool {
if s.canReadDocument == nil {
return true
}
allowed, err := s.canReadDocument(ctx, userID, documentID)
if err != nil {
s.logger.Warn("authorize watcher", "user_id", userID, "document_id", documentID, "error", err)
return false
}
return allowed
} }
func (s *Service) broadcastNotification(userID string) { func (s *Service) broadcastNotification(userID string) {

View File

@@ -4,10 +4,27 @@ import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt"
"path"
"strings" "strings"
"time" "time"
) )
func NormalizeFolderPath(raw string) (string, error) {
raw = strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/"))
if raw == "" || raw == "/" {
return "", nil
}
clean := path.Clean(strings.Trim(raw, "/"))
if clean == "." {
return "", nil
}
if clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("folder path must remain inside the content root")
}
return clean, nil
}
func randomID(prefix string) string { func randomID(prefix string) string {
buf := make([]byte, 12) buf := make([]byte, 12)
if _, err := rand.Read(buf); err != nil { if _, err := rand.Read(buf); err != nil {

View File

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

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, serverToken: serverToken,
apiURL: strings.TrimRight(apiURL, "/"), apiURL: strings.TrimRight(apiURL, "/"),
from: from, from: from,
client: &http.Client{Timeout: postmarkTimeout}, client: &http.Client{
logger: logger, Timeout: postmarkTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
logger: logger,
} }
} }
type postmarkRequest struct { type postmarkRequest struct {
From string `json:"From"` From string `json:"From"`
To string `json:"To"` To string `json:"To"`
Subject string `json:"Subject"` Subject string `json:"Subject"`
TextBody string `json:"TextBody"` TextBody string `json:"TextBody"`
Headers []postmarkHeader `json:"Headers,omitempty"` Headers []postmarkHeader `json:"Headers,omitempty"`
} }
@@ -66,13 +71,13 @@ type postmarkResponse struct {
// send if they need different reply-to addresses. // send if they need different reply-to addresses.
func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body string) error { func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body string) error {
if p.serverToken == "" { if p.serverToken == "" {
return fmt.Errorf("postmark server token not configured") return &PermanentError{Cause: fmt.Errorf("postmark server token not configured")}
} }
if p.from == "" { if p.from == "" {
return fmt.Errorf("postmark from address not configured") return &PermanentError{Cause: fmt.Errorf("postmark from address not configured")}
} }
if len(to) == 0 { if len(to) == 0 {
return fmt.Errorf("postmark send requires at least one recipient") return &PermanentError{Cause: fmt.Errorf("postmark send requires at least one recipient")}
} }
payload := postmarkRequest{ payload := postmarkRequest{
@@ -96,7 +101,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st
resp, err := p.client.Do(req) resp, err := p.client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("postmark request: %w", err) return &TemporaryError{Cause: fmt.Errorf("postmark request: %w", err)}
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -107,7 +112,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes))) return &PermanentError{Cause: fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))}
} }
var parsed postmarkResponse var parsed postmarkResponse
@@ -117,7 +122,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st
if parsed.ErrorCode != 0 { if parsed.ErrorCode != 0 {
// Postmark distinguishes "inactive recipient" (406) and other errors. Treat // Postmark distinguishes "inactive recipient" (406) and other errors. Treat
// 406 as non-retryable; others bubble up as generic failures. // 406 as non-retryable; others bubble up as generic failures.
return fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message) return &PermanentError{Cause: fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message)}
} }
p.logger.Info("postmark email sent", "to", parsed.To, "subject", subject) p.logger.Info("postmark email sent", "to", parsed.To, "subject", subject)
@@ -139,3 +144,11 @@ type TemporaryError struct {
func (t *TemporaryError) Error() string { return t.Cause.Error() } func (t *TemporaryError) Error() string { return t.Cause.Error() }
func (t *TemporaryError) Unwrap() error { return t.Cause } func (t *TemporaryError) Unwrap() error { return t.Cause }
// PermanentError marks a provider rejection that cannot succeed on retry.
type PermanentError struct {
Cause error
}
func (p *PermanentError) Error() string { return p.Cause.Error() }
func (p *PermanentError) Unwrap() error { return p.Cause }

View File

@@ -3,6 +3,7 @@ package email
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math" "math"
@@ -211,17 +212,28 @@ func (q *Queue) deliver(ctx context.Context, row QueueRow) error {
} }
// Failure: retry or give up. // Failure: retry or give up.
if row.Attempts >= row.MaxAttempts { var permanent *PermanentError
if errors.As(err, &permanent) {
if _, ferr := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
`, truncateErr(err.Error()), row.ID); ferr != nil {
return fmt.Errorf("mark permanent failure: %w", ferr)
}
q.logger.Warn("email permanently rejected", "id", row.ID, "attempts", row.Attempts+1, "error", err)
return nil
}
currentAttempt := row.Attempts + 1
if currentAttempt >= row.MaxAttempts {
if _, ferr := q.db.ExecContext(ctx, ` if _, ferr := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ? UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
`, truncateErr(err.Error()), row.ID); ferr != nil { `, truncateErr(err.Error()), row.ID); ferr != nil {
return fmt.Errorf("mark failed: %w", ferr) return fmt.Errorf("mark failed: %w", ferr)
} }
q.logger.Warn("email permanently failed", "id", row.ID, "attempts", row.Attempts, "error", err) q.logger.Warn("email permanently failed", "id", row.ID, "attempts", currentAttempt, "error", err)
return nil return nil
} }
backoff := q.backoff(row.Attempts) backoff := q.backoff(currentAttempt)
next := time.Now().UTC().Add(backoff) next := time.Now().UTC().Add(backoff)
if _, err := q.db.ExecContext(ctx, ` if _, err := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'pending', next_attempt_at = ?, last_error = ? WHERE id = ? UPDATE email_queue SET status = 'pending', next_attempt_at = ?, last_error = ? WHERE id = ?
@@ -229,9 +241,9 @@ func (q *Queue) deliver(ctx context.Context, row QueueRow) error {
return fmt.Errorf("schedule retry: %w", err) return fmt.Errorf("schedule retry: %w", err)
} }
// TemporaryError (Postmark 5xx) is retried with the same backoff as // Temporary provider failures and unclassified sender failures are retried;
// other errors; the max-attempts cap still bounds total work. // PermanentError is handled above without wasting additional attempts.
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", row.Attempts, "backoff", backoff, "error", err) q.logger.Info("email retry scheduled", "id", row.ID, "attempt", currentAttempt, "backoff", backoff, "error", err)
return nil return nil
} }

View File

@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -152,9 +153,9 @@ func TestQueue_RetryThenSucceed(t *testing.T) {
go q.Run(ctx) go q.Run(ctx)
if err := waitFor(ctx, func() bool { if err := waitFor(ctx, func() bool {
return sender.successCount >= 1 return sender.successCount.Load() >= 1
}); err != nil { }); err != nil {
t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount, sender.attempts, err) t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount.Load(), sender.attempts.Load(), err)
} }
// Verify the row is marked sent. // Verify the row is marked sent.
@@ -196,6 +197,32 @@ func TestQueue_PermanentFailure(t *testing.T) {
} }
} }
func TestQueue_ProviderRejectionFailsImmediately(t *testing.T) {
db := newTestDB(t)
sender := &recordingSender{err: &PermanentError{Cause: errors.New("sender domain not verified")}}
q := NewQueue(db, sender, testLogger(t))
id, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Fail", "Body")
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
rows, err := q.claimBatch(context.Background(), 1)
if err != nil || len(rows) != 1 {
t.Fatalf("claimBatch = %#v, %v", rows, err)
}
if err := q.deliver(context.Background(), rows[0]); err != nil {
t.Fatalf("deliver: %v", err)
}
var status string
var attempts int
if err := db.QueryRow(`SELECT status, attempts FROM email_queue WHERE id = ?`, id).Scan(&status, &attempts); err != nil {
t.Fatalf("query row: %v", err)
}
if status != "failed" || attempts != 1 {
t.Fatalf("status=%q attempts=%d, want failed after one attempt", status, attempts)
}
}
func TestQueue_NilSenderLeavesPending(t *testing.T) { func TestQueue_NilSenderLeavesPending(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t)) q := NewQueue(db, nil, testLogger(t))
@@ -218,17 +245,17 @@ func TestQueue_NilSenderLeavesPending(t *testing.T) {
// flakySender fails the first N attempts then succeeds. // flakySender fails the first N attempts then succeeds.
type flakySender struct { type flakySender struct {
failTimes int failTimes int32
attempts int attempts atomic.Int32
successCount int successCount atomic.Int32
} }
func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error { func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error {
f.attempts++ attempt := f.attempts.Add(1)
if f.attempts <= f.failTimes { if attempt <= f.failTimes {
return errors.New("transient failure") return errors.New("transient failure")
} }
f.successCount++ f.successCount.Add(1)
return nil return nil
} }

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ import (
"testing" "testing"
"github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/collaboration"
"github.com/tim/cairnquire/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database" "github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/docs"
@@ -99,6 +100,13 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo
} }
syncRepo := sync.NewRepository(db.SQL()) syncRepo := sync.NewRepository(db.SQL())
hub := realtime.NewHub(logger)
collabService := collaboration.NewServiceWithOptions(
collaboration.NewRepository(db.SQL()),
hub,
logger,
collaboration.Options{UserLookup: auth.NewRepository(db.SQL()), PublicOrigin: "http://localhost:8080"},
)
handler, err := New(Dependencies{ handler, err := New(Dependencies{
Config: config.Config{ Config: config.Config{
Content: config.ContentConfig{ Content: config.ContentConfig{
@@ -108,14 +116,15 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"}, Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
DevMode: devMode, DevMode: devMode,
}, },
Logger: logger, Logger: logger,
Documents: docService, Documents: docService,
Repository: docRepo, Repository: docRepo,
ContentStore: contentStore, ContentStore: contentStore,
Hub: realtime.NewHub(logger), Hub: hub,
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger), SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
SyncRepo: syncRepo, SyncRepo: syncRepo,
Auth: authService, Auth: authService,
Collaboration: collabService,
}) })
if err != nil { if err != nil {
t.Fatalf("create http server: %v", err) t.Fatalf("create http server: %v", err)

View File

@@ -3,6 +3,8 @@ package httpserver
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/collaboration" "github.com/tim/cairnquire/apps/server/internal/collaboration"
@@ -32,11 +34,12 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
} }
anchorHash := r.URL.Query().Get("anchor_hash") anchorHash := r.URL.Query().Get("anchor_hash")
includeResolved := r.URL.Query().Get("include_resolved") == "true"
var comments []collaboration.Comment var comments []collaboration.Comment
if anchorHash != "" { if anchorHash != "" {
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash) comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash, includeResolved)
} else { } else {
comments, err = s.collaboration.ListComments(r.Context(), documentID) comments, err = s.collaboration.ListComments(r.Context(), documentID, includeResolved)
} }
if err != nil { if err != nil {
s.logger.Warn("list comments", "error", err) s.logger.Warn("list comments", "error", err)
@@ -128,8 +131,13 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
unreadOnly := r.URL.Query().Get("unread") == "true" unreadOnly := r.URL.Query().Get("unread") == "true"
limit := 50 limit := 50
if r.URL.Query().Get("limit") != "" { if rawLimit := r.URL.Query().Get("limit"); rawLimit != "" {
// Parse limit if needed, but for now just use default parsed, err := strconv.Atoi(rawLimit)
if err != nil || parsed < 1 || parsed > 100 {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
return
}
limit = parsed
} }
notifications, err := s.collaboration.ListNotifications(r.Context(), principal, unreadOnly, limit) notifications, err := s.collaboration.ListNotifications(r.Context(), principal, unreadOnly, limit)
@@ -204,6 +212,21 @@ func (s *Server) handleWatchDocument(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return return
} }
req.DocumentID = strings.TrimSpace(req.DocumentID)
document, err := s.repository.GetDocumentByID(r.Context(), req.DocumentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canRead, err := s.canReadDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canRead {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
if err := s.collaboration.WatchDocument(r.Context(), principal, req.DocumentID); err != nil { if err := s.collaboration.WatchDocument(r.Context(), principal, req.DocumentID); err != nil {
s.logger.Warn("watch document", "error", err) s.logger.Warn("watch document", "error", err)
@@ -227,6 +250,7 @@ func (s *Server) handleUnwatchDocument(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return return
} }
req.DocumentID = strings.TrimSpace(req.DocumentID)
if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil { if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil {
s.logger.Warn("unwatch document", "error", err) s.logger.Warn("unwatch document", "error", err)
@@ -250,8 +274,17 @@ func (s *Server) handleWatchFolder(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return return
} }
normalized, err := collaboration.NormalizeFolderPath(req.FolderPath)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if !s.canReadFolder(r, normalized) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "folder not found"})
return
}
if err := s.collaboration.WatchFolder(r.Context(), principal, req.FolderPath); err != nil { if err := s.collaboration.WatchFolder(r.Context(), principal, normalized); err != nil {
s.logger.Warn("watch folder", "error", err) s.logger.Warn("watch folder", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
@@ -273,8 +306,13 @@ func (s *Server) handleUnwatchFolder(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return return
} }
normalized, err := collaboration.NormalizeFolderPath(req.FolderPath)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := s.collaboration.UnwatchFolder(r.Context(), principal, req.FolderPath); err != nil { if err := s.collaboration.UnwatchFolder(r.Context(), principal, normalized); err != nil {
s.logger.Warn("unwatch folder", "error", err) s.logger.Warn("unwatch folder", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
@@ -290,6 +328,20 @@ func (s *Server) handleIsWatching(w http.ResponseWriter, r *http.Request) {
} }
documentID := r.URL.Query().Get("document_id") documentID := r.URL.Query().Get("document_id")
document, err := s.repository.GetDocumentByID(r.Context(), documentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canRead, err := s.canReadDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canRead {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
watching, err := s.collaboration.IsWatching(r.Context(), principal, documentID) watching, err := s.collaboration.IsWatching(r.Context(), principal, documentID)
if err != nil { if err != nil {
s.logger.Warn("is watching", "error", err) s.logger.Warn("is watching", "error", err)

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

View File

@@ -207,7 +207,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
case strings.HasPrefix(path, "/api/content/"): case strings.HasPrefix(path, "/api/content/"):
return auth.ScopeSyncRead, true return auth.ScopeSyncRead, true
case strings.HasPrefix(path, "/api/comments"): case strings.HasPrefix(path, "/api/comments"):
return auth.ScopeDocsRead, true if method == http.MethodGet {
return auth.ScopeDocsRead, true
}
return auth.ScopeDocsWrite, true
case strings.HasPrefix(path, "/api/notifications"): case strings.HasPrefix(path, "/api/notifications"):
return auth.ScopeDocsRead, true return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/watch"): case strings.HasPrefix(path, "/api/watch"):

View File

@@ -1,23 +1,29 @@
(function () { (function () {
'use strict'; 'use strict';
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath; const article = document.querySelector('[data-document-id]');
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash; if (!article) return;
const canComment = document.querySelector('[data-document-path]')?.dataset.canComment === 'true';
const article = document.querySelector('[data-document-path]'); const documentId = article.dataset.documentId;
const workspaceShell = article?.closest('.workspace-shell--document'); const documentHash = article.dataset.documentHash;
const canComment = article.dataset.canComment === 'true';
const workspaceShell = article.closest('.workspace-shell--document');
const commentsAsideList = document.querySelector('[data-comments-aside-list]'); const commentsAsideList = document.querySelector('[data-comments-aside-list]');
const commentsAsideListMobile = document.querySelector('[data-comments-aside-list-mobile]'); const commentsAsideListMobile = document.querySelector('[data-comments-aside-list-mobile]');
const stripTrack = document.querySelector('[data-comments-strip-track]'); const stripTrack = document.querySelector('[data-comments-strip-track]');
const commentsAside = document.querySelector('[data-comments-aside]'); const commentsAside = document.querySelector('[data-comments-aside]');
const commentsSheetToggle = document.querySelector('[data-toggle-comments]'); const commentsSheetToggle = document.querySelector('[data-toggle-comments]');
const commentsSheetClose = document.querySelector('[data-close-comments]'); const commentsSheetClose = document.querySelector('[data-close-comments]');
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
const commentToggle = document.querySelector('[data-comment-toggle]'); const commentToggle = document.querySelector('[data-comment-toggle]');
const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null; const watchButton = document.querySelector('[data-document-watch]');
const historyButtons = document.querySelectorAll('[data-comments-history]');
const commentVisibilityKey = `cairnquire:document-comments:${documentId}`;
const commentsByAnchor = new Map();
const elementsByAnchor = new Map();
let includeResolved = false;
let watching = false;
function readCommentVisibility() { function readCommentVisibility() {
if (!commentVisibilityKey) return false;
try { try {
return window.localStorage.getItem(commentVisibilityKey) === 'hidden'; return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
} catch (err) { } catch (err) {
@@ -25,29 +31,17 @@
} }
} }
if (!documentId) return;
function syncVisibility() {
const hidden = readCommentVisibility();
if (article) article.classList.toggle('comments-hidden', hidden);
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
}
syncVisibility();
function commentsHidden() { function commentsHidden() {
return Boolean(workspaceShell?.classList.contains('comments-visible')) === false; return !workspaceShell?.classList.contains('comments-visible');
} }
function setCommentsHidden(hidden) { function setCommentsHidden(hidden) {
if (article) article.classList.toggle('comments-hidden', hidden); article.classList.toggle('comments-hidden', hidden);
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden); workspaceShell?.classList.toggle('comments-visible', !hidden);
if (commentVisibilityKey) { try {
try { window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible'); } catch (err) {
} catch (err) { // Storage can be unavailable in private browsing contexts.
// Ignore persistence failures
}
} }
if (commentToggle) { if (commentToggle) {
commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true'); commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true');
@@ -60,173 +54,218 @@
} }
} }
if (commentToggle) { setCommentsHidden(readCommentVisibility());
commentToggle.setAttribute('aria-pressed', commentsHidden() ? 'false' : 'true'); commentToggle?.addEventListener('click', () => setCommentsHidden(!commentsHidden()));
commentToggle.addEventListener('click', () => {
setCommentsHidden(!commentsHidden());
});
}
function hashParagraph(el) { function hashParagraph(el) {
const text = (el.textContent || '').trim(); const text = (el.textContent || '').trim();
if (!text) return null; if (!text) return null;
let hash = 0; let hash = 0;
for (let i = 0; i < text.length; i++) { for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i); hash = ((hash << 5) - hash) + text.charCodeAt(i);
hash = ((hash << 5) - hash) + char; hash &= hash;
hash = hash & hash;
} }
return 'h' + Math.abs(hash).toString(36); return 'h' + Math.abs(hash).toString(36);
} }
function addCommentButton(el, hash) { function addCommentButton(el, anchorHash) {
const btn = document.createElement('button'); const button = document.createElement('button');
btn.className = 'comment-anchor-btn'; button.className = 'comment-anchor-btn';
btn.type = 'button'; button.type = 'button';
btn.title = 'Add comment'; button.title = 'Add comment';
btn.setAttribute('aria-label', 'Add comment'); button.setAttribute('aria-label', 'Add comment');
btn.innerHTML = ` button.innerHTML = `
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path> <path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
<path d="M12 8v4"></path> <path d="M12 8v4"></path><path d="M10 10h4"></path>
<path d="M10 10h4"></path>
</svg> </svg>
<span class="comment-anchor-count" aria-hidden="true" hidden></span> <span class="comment-anchor-count" aria-hidden="true" hidden></span>
<span class="sr-only">Add comment</span> <span class="sr-only">Add comment</span>`;
`; button.addEventListener('click', () => createComment(anchorHash));
btn.addEventListener('click', () => promptComment(el, hash)); el.appendChild(button);
el.appendChild(btn);
} }
function updateCommentButtonState(el, count) { function updateCommentButtonState(el, comments) {
const btn = el.querySelector('.comment-anchor-btn'); const button = el.querySelector('.comment-anchor-btn');
if (!btn) return; const count = comments.filter(comment => !comment.resolvedAt).length;
const hasComments = count > 0; if (button) {
btn.classList.toggle('comment-anchor-btn--has-comments', hasComments); const hasComments = count > 0;
btn.setAttribute('aria-label', hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment'); button.classList.toggle('comment-anchor-btn--has-comments', hasComments);
btn.title = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment'; const label = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
const countNode = btn.querySelector('.comment-anchor-count'); button.setAttribute('aria-label', label);
if (countNode) { button.title = label;
countNode.hidden = !hasComments; const countNode = button.querySelector('.comment-anchor-count');
countNode.textContent = count > 99 ? '99+' : String(count); if (countNode) {
countNode.hidden = !hasComments;
countNode.textContent = count > 99 ? '99+' : String(count);
}
} }
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
} }
async function promptComment(el, anchorHash) { async function createComment(anchorHash, parentId) {
const text = window.prompt('Comment on this paragraph:'); const promptText = parentId ? 'Reply to this comment:' : 'Comment on this section:';
if (!text || !text.trim()) return; const content = window.prompt(promptText);
if (!content || !content.trim()) return;
try { try {
const res = await fetch('/api/comments', { const response = await fetch('/api/comments', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId, versionHash: documentHash, content: text.trim(), anchorHash }), body: JSON.stringify({
documentId,
versionHash: documentHash,
content: content.trim(),
anchorHash,
parentId: parentId || undefined,
}),
}); });
if (!res.ok) throw new Error(await res.text()); if (!response.ok) throw new Error(await response.text());
await loadCommentsForAnchor(el, anchorHash); await loadCommentsForAnchor(anchorHash);
} catch (err) { } catch (err) {
window.alert('Failed to post comment: ' + err.message); window.alert('Failed to post comment: ' + err.message);
} }
} }
async function loadCommentsForAnchor(el, anchorHash) { async function resolveThread(comment, anchorHash) {
if (!window.confirm('Resolve this comment thread?')) return;
try { try {
const res = await fetch(`/api/comments?document_id=${encodeURIComponent(documentId)}&anchor_hash=${encodeURIComponent(anchorHash)}`); const response = await fetch(`/api/comments/${encodeURIComponent(comment.id)}/resolve`, { method: 'POST' });
if (!res.ok) return; if (!response.ok) throw new Error(await response.text());
const data = await res.json(); await loadCommentsForAnchor(anchorHash);
renderComments(el, data.comments || []); } catch (err) {
window.alert('Failed to resolve comment: ' + err.message);
}
}
async function loadCommentsForAnchor(anchorHash) {
const element = elementsByAnchor.get(anchorHash);
if (!element) return;
const params = new URLSearchParams({ document_id: documentId, anchor_hash: anchorHash });
if (includeResolved) params.set('include_resolved', 'true');
try {
const response = await fetch(`/api/comments?${params}`);
if (!response.ok) return;
const data = await response.json();
const comments = data.comments || [];
commentsByAnchor.set(anchorHash, comments);
renderInlineComments(element, anchorHash, comments);
renderCommentsAside();
updateStripMarkers();
} catch (err) { } catch (err) {
console.error('load comments', err); console.error('load comments', err);
} }
} }
function renderComments(el, comments) { function buildCommentForest(comments) {
const existing = el.querySelector('.comment-list'); const nodes = new Map(comments.map(comment => [comment.id, { comment, children: [] }]));
if (existing) existing.remove(); const roots = [];
comments.forEach(comment => {
updateCommentButtonState(el, comments.length); const node = nodes.get(comment.id);
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false'; const parent = comment.parentId ? nodes.get(comment.parentId) : null;
if (parent) parent.children.push(node);
if (!comments.length) { else roots.push(node);
renderCommentsAside();
updateStripMarkers();
return;
}
const list = document.createElement('div');
list.className = 'comment-list';
comments.forEach(c => {
const item = document.createElement('div');
item.className = 'comment-item';
item.innerHTML = `
<div class="comment-meta"><strong>${escapeHtml(c.authorName || 'Anonymous')}</strong><time>${formatDate(c.createdAt)}</time></div>
<div class="comment-body">${escapeHtml(c.content)}</div>
`;
list.appendChild(item);
}); });
el.appendChild(list); return roots;
renderCommentsAside();
updateStripMarkers();
} }
function buildAsideItemFor(el) { function renderThreadNode(node, anchorHash, variant) {
const comments = []; const comment = node.comment;
el.querySelectorAll('.comment-item').forEach(item => { const wrapper = document.createElement('div');
const meta = item.querySelector('.comment-meta'); wrapper.className = `comment-thread${comment.resolvedAt ? ' is-resolved' : ''}`;
const bodyEl = item.querySelector('.comment-body'); wrapper.dataset.commentId = comment.id;
comments.push({
authorName: meta?.querySelector('strong')?.textContent || 'Anonymous', const item = document.createElement('div');
createdAt: meta?.querySelector('time')?.textContent || '', item.className = variant === 'aside' ? 'comments-aside-item__comment' : 'comment-item';
content: bodyEl?.textContent || '',
}); const meta = document.createElement('div');
}); meta.className = variant === 'aside' ? 'comments-aside-item__meta' : 'comment-meta';
const author = document.createElement('strong');
author.textContent = comment.authorName || 'Anonymous';
const time = document.createElement('time');
time.textContent = formatDate(comment.createdAt);
meta.append(author, time);
if (comment.resolvedAt) {
const status = document.createElement('span');
status.className = 'comment-status';
status.textContent = 'Resolved';
meta.appendChild(status);
}
const body = document.createElement('div');
body.className = variant === 'aside' ? 'comments-aside-item__body' : 'comment-body';
body.textContent = comment.content;
item.append(meta, body);
if (canComment && !comment.resolvedAt) {
const actions = document.createElement('div');
actions.className = 'comment-actions';
const reply = document.createElement('button');
reply.type = 'button';
reply.className = 'comment-action';
reply.textContent = 'Reply';
reply.addEventListener('click', () => createComment(anchorHash, comment.id));
actions.appendChild(reply);
if (!comment.parentId) {
const resolve = document.createElement('button');
resolve.type = 'button';
resolve.className = 'comment-action';
resolve.textContent = 'Resolve thread';
resolve.addEventListener('click', () => resolveThread(comment, anchorHash));
actions.appendChild(resolve);
}
item.appendChild(actions);
}
wrapper.appendChild(item);
if (node.children.length) {
const replies = document.createElement('div');
replies.className = 'comment-thread__replies';
node.children.forEach(child => replies.appendChild(renderThreadNode(child, anchorHash, variant)));
wrapper.appendChild(replies);
}
return wrapper;
}
function renderInlineComments(el, anchorHash, comments) {
el.querySelector('.comment-list')?.remove();
updateCommentButtonState(el, comments);
if (!comments.length) return;
const list = document.createElement('div');
list.className = 'comment-list';
buildCommentForest(comments).forEach(root => list.appendChild(renderThreadNode(root, anchorHash, 'inline')));
el.appendChild(list);
}
function buildAsideItem(anchorHash, el, comments) {
if (!comments.length) return null; if (!comments.length) return null;
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
const truncated = targetText.length > 120 ? targetText + '…' : targetText;
const asideItem = document.createElement('div'); const asideItem = document.createElement('div');
asideItem.className = 'comments-aside-item'; asideItem.className = 'comments-aside-item';
asideItem.dataset.anchorHash = el.dataset.anchorHash; asideItem.dataset.anchorHash = anchorHash;
const targetEl = document.createElement('div');
targetEl.className = 'comments-aside-item__target';
targetEl.textContent = truncated || 'Commented section';
targetEl.addEventListener('click', () => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
asideItem.appendChild(targetEl);
comments.forEach(c => {
const commentEl = document.createElement('div');
commentEl.className = 'comments-aside-item__comment';
commentEl.innerHTML = `
<div class="comments-aside-item__meta"><strong>${escapeHtml(c.authorName)}</strong><time>${escapeHtml(c.createdAt)}</time></div>
<div class="comments-aside-item__body">${escapeHtml(c.content)}</div>
`;
asideItem.appendChild(commentEl);
});
const target = document.createElement('button');
target.className = 'comments-aside-item__target';
target.type = 'button';
target.textContent = el.dataset.commentTarget || 'Commented section';
target.addEventListener('click', () => el.scrollIntoView({ behavior: 'smooth', block: 'center' }));
asideItem.appendChild(target);
buildCommentForest(comments).forEach(root => asideItem.appendChild(renderThreadNode(root, anchorHash, 'aside')));
return asideItem; return asideItem;
} }
function renderCommentsInto(container) { function renderCommentsInto(container) {
if (!container) return; if (!container) return;
container.innerHTML = ''; container.innerHTML = '';
let rendered = 0;
const body = document.querySelector('.markdown-body'); elementsByAnchor.forEach((el, anchorHash) => {
if (!body) return; const item = buildAsideItem(anchorHash, el, commentsByAnchor.get(anchorHash) || []);
if (item) {
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]'); container.appendChild(item);
if (!commentables.length) { rendered++;
container.innerHTML = '<p class="document-comments-aside__empty">No comments yet.</p>'; }
return;
}
commentables.forEach(el => {
const item = buildAsideItemFor(el);
if (item) container.appendChild(item);
}); });
if (!rendered) {
container.innerHTML = `<p class="document-comments-aside__empty">${includeResolved ? 'No comments found.' : 'No active comments yet.'}</p>`;
}
} }
function renderCommentsAside() { function renderCommentsAside() {
@@ -237,177 +276,148 @@
function updateStripMarkers() { function updateStripMarkers() {
if (!stripTrack) return; if (!stripTrack) return;
stripTrack.innerHTML = ''; stripTrack.innerHTML = '';
const body = document.querySelector('.markdown-body');
const shell = document.querySelector('.document-shell'); const shell = document.querySelector('.document-shell');
if (!body || !shell) return; if (!shell) return;
elementsByAnchor.forEach((el, anchorHash) => {
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]'); if (!(commentsByAnchor.get(anchorHash) || []).length) return;
const shellRect = shell.getBoundingClientRect();
const trackHeight = stripTrack.clientHeight;
commentables.forEach(el => {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const shellRect = shell.getBoundingClientRect();
const relativeTop = rect.top - shellRect.top + shell.scrollTop; const relativeTop = rect.top - shellRect.top + shell.scrollTop;
const percent = (relativeTop / shell.scrollHeight) * 100; const percent = (relativeTop / shell.scrollHeight) * 100;
const clamped = Math.max(0, Math.min(95, percent));
const marker = document.createElement('button'); const marker = document.createElement('button');
marker.className = 'document-comments-strip__marker'; marker.className = 'document-comments-strip__marker';
marker.type = 'button'; marker.type = 'button';
marker.style.top = clamped + '%'; marker.style.top = Math.max(0, Math.min(95, percent)) + '%';
marker.dataset.anchorHash = el.dataset.anchorHash; marker.dataset.anchorHash = anchorHash;
marker.title = 'Jump to comments'; marker.title = 'Jump to comments';
marker.addEventListener('click', () => { marker.addEventListener('click', () => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (commentsHidden()) setCommentsHidden(false); if (commentsHidden()) setCommentsHidden(false);
// Highlight the corresponding sidebar item commentsAsideList?.querySelector(`[data-anchor-hash="${anchorHash}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
const asideItem = commentsAsideList?.querySelector(`[data-anchor-hash="${el.dataset.anchorHash}"]`);
if (asideItem) {
asideItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
asideItem.classList.add('is-highlighted');
setTimeout(() => asideItem.classList.remove('is-highlighted'), 1500);
}
}); });
stripTrack.appendChild(marker); stripTrack.appendChild(marker);
}); });
} }
let bidirectionalHoverInitialized = false;
function initBidirectionalHover() { function initBidirectionalHover() {
if (bidirectionalHoverInitialized) return;
bidirectionalHoverInitialized = true;
const body = document.querySelector('.markdown-body'); const body = document.querySelector('.markdown-body');
if (!body || !commentsAsideList) return; if (!body || !commentsAsideList) return;
body.addEventListener('mouseover', event => toggleHighlight(event.target.closest('.commentable'), true));
// Body → sidebar + marker body.addEventListener('mouseout', event => toggleHighlight(event.target.closest('.commentable'), false));
body.addEventListener('mouseenter', (e) => { commentsAsideList.addEventListener('mouseover', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), true));
const el = e.target.closest('.commentable[data-has-comments="true"]'); commentsAsideList.addEventListener('mouseout', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), false));
if (!el) return;
const hash = el.dataset.anchorHash;
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
if (asideItem) asideItem.classList.add('is-highlighted');
if (marker) marker.classList.add('is-active');
el.classList.add('is-highlighted');
}, true);
body.addEventListener('mouseleave', (e) => {
const el = e.target.closest('.commentable[data-has-comments="true"]');
if (!el) return;
const hash = el.dataset.anchorHash;
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
if (asideItem) asideItem.classList.remove('is-highlighted');
if (marker) marker.classList.remove('is-active');
el.classList.remove('is-highlighted');
}, true);
// Sidebar → paragraph + marker (event delegation so it survives re-renders)
commentsAsideList.addEventListener('mouseenter', (e) => {
const item = e.target.closest('.comments-aside-item');
if (!item) return;
const hash = item.dataset.anchorHash;
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
if (para) para.classList.add('is-highlighted');
if (marker) marker.classList.add('is-active');
item.classList.add('is-highlighted');
}, true);
commentsAsideList.addEventListener('mouseleave', (e) => {
const item = e.target.closest('.comments-aside-item');
if (!item) return;
const hash = item.dataset.anchorHash;
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
if (para) para.classList.remove('is-highlighted');
if (marker) marker.classList.remove('is-active');
item.classList.remove('is-highlighted');
}, true);
} }
function escapeHtml(str) { function toggleHighlight(el, active) {
const div = document.createElement('div'); if (!el) return;
div.textContent = str; const hash = el.dataset.anchorHash;
return div.innerHTML; el.classList.toggle('is-highlighted', active);
commentsAsideList?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-highlighted', active);
stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-active', active);
} }
function formatDate(iso) { function toggleAsideHighlight(item, active) {
const d = new Date(iso); if (!item) return;
return d.toLocaleString(); toggleHighlight(elementsByAnchor.get(item.dataset.anchorHash), active);
}
function commentsSheetOpen() {
return commentsAside?.classList.contains('is-open');
} }
function setCommentsSheetOpen(open) { function setCommentsSheetOpen(open) {
if (!commentsAside) return; if (!commentsAside) return;
commentsAside.classList.toggle('is-open', open); commentsAside.classList.toggle('is-open', open);
if (commentsSheetToggle) { commentsSheetToggle?.setAttribute('aria-expanded', String(open));
commentsSheetToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
commentsSheetToggle.setAttribute('aria-label', open ? 'Hide comments' : 'Show comments');
}
if (open) { if (open) {
document.body.classList.add('drawer-open'); document.body.classList.add('drawer-open');
renderCommentsAside(); renderCommentsAside();
} else { } else if (!document.querySelector('[data-picker-drawer].is-open, [data-meta-drawer].is-open')) {
if (!pickerOpen() && !metaOpen()) { document.body.classList.remove('drawer-open');
document.body.classList.remove('drawer-open');
}
} }
} }
function pickerOpen() { commentsSheetToggle?.addEventListener('click', () => setCommentsSheetOpen(!commentsAside?.classList.contains('is-open')));
return document.querySelector('[data-picker-drawer]')?.classList.contains('is-open'); commentsSheetClose?.addEventListener('click', () => setCommentsSheetOpen(false));
function setWatchState(nextWatching) {
watching = nextWatching;
if (!watchButton) return;
watchButton.setAttribute('aria-pressed', String(watching));
const label = watchButton.querySelector('[data-document-watch-label]');
if (label) label.textContent = watching ? 'Unwatch document' : 'Watch document';
} }
function metaOpen() { async function loadWatchStatus() {
return document.querySelector('[data-meta-drawer]')?.classList.contains('is-open'); if (!watchButton) return;
try {
const response = await fetch(`/api/watch/status?document_id=${encodeURIComponent(documentId)}`);
if (!response.ok) return;
const data = await response.json();
setWatchState(Boolean(data.watching));
} catch (err) {
console.error('load watch status', err);
}
} }
if (commentsSheetToggle) { async function toggleWatch() {
commentsSheetToggle.addEventListener('click', () => { if (!watchButton) return;
setCommentsSheetOpen(!commentsSheetOpen()); watchButton.disabled = true;
try {
const response = await fetch('/api/watch/document', {
method: watching ? 'DELETE' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId }),
});
if (!response.ok) throw new Error(await response.text());
setWatchState(!watching);
} catch (err) {
window.alert('Failed to update watch: ' + err.message);
} finally {
watchButton.disabled = false;
}
}
watchButton?.addEventListener('click', toggleWatch);
function updateHistoryButtons() {
historyButtons.forEach(button => {
button.setAttribute('aria-pressed', String(includeResolved));
button.textContent = includeResolved ? 'Hide resolved' : 'Show resolved';
}); });
} }
if (commentsSheetClose) { historyButtons.forEach(button => button.addEventListener('click', async () => {
commentsSheetClose.addEventListener('click', () => setCommentsSheetOpen(false)); includeResolved = !includeResolved;
updateHistoryButtons();
await Promise.all(Array.from(elementsByAnchor.keys(), loadCommentsForAnchor));
}));
function formatDate(iso) {
return new Date(iso).toLocaleString();
} }
function init() { function init() {
const body = document.querySelector('.markdown-body'); const body = document.querySelector('.markdown-body');
if (!body) return; if (!body) return;
const commentables = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
const paragraphs = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre'); commentables.forEach(el => {
paragraphs.forEach(el => { const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim();
const hash = hashParagraph(el); const anchorHash = hashParagraph(el);
if (!hash) return; if (!anchorHash) return;
el.dataset.anchorHash = hash; el.dataset.anchorHash = anchorHash;
el.dataset.commentTarget = targetText.length > 120 ? targetText.slice(0, 120) + '…' : targetText;
el.classList.add('commentable'); el.classList.add('commentable');
if (canComment) addCommentButton(el, hash); elementsByAnchor.set(anchorHash, el);
loadCommentsForAnchor(el, hash); if (canComment) addCommentButton(el, anchorHash);
loadCommentsForAnchor(anchorHash);
}); });
renderCommentsAside(); renderCommentsAside();
initBidirectionalHover(); initBidirectionalHover();
loadWatchStatus();
// Update markers on scroll/resize updateHistoryButtons();
const shell = document.querySelector('.document-shell'); const shell = document.querySelector('.document-shell');
if (shell) { shell?.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
shell.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
}
window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers)); window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers));
// Initial marker update after layout settles
setTimeout(updateStripMarkers, 100); setTimeout(updateStripMarkers, 100);
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
document.addEventListener('DOMContentLoaded', init); else init();
} else {
init();
}
})(); })();

View File

@@ -7,6 +7,7 @@
const trigger = bell.querySelector('.notification-bell__trigger'); const trigger = bell.querySelector('.notification-bell__trigger');
const countEl = bell.querySelector('[data-unread-count]'); const countEl = bell.querySelector('[data-unread-count]');
const dropdown = bell.querySelector('[data-notification-dropdown]'); const dropdown = bell.querySelector('[data-notification-dropdown]');
if (!trigger || !countEl || !dropdown) return;
let isOpen = false; let isOpen = false;
trigger.addEventListener('click', (e) => { trigger.addEventListener('click', (e) => {
@@ -76,31 +77,42 @@
notifications.forEach(n => { notifications.forEach(n => {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = n.readAt ? 'notification-item is-read' : 'notification-item'; li.className = n.readAt ? 'notification-item is-read' : 'notification-item';
li.innerHTML = ` const button = document.createElement('button');
button.type = 'button';
button.className = 'notification-item__button';
button.innerHTML = `
<div class="notification-message">${escapeHtml(n.message)}</div> <div class="notification-message">${escapeHtml(n.message)}</div>
<time class="notification-time">${formatDate(n.createdAt)}</time> <time class="notification-time">${formatDate(n.createdAt)}</time>
`; `;
if (!n.readAt) { button.addEventListener('click', async () => {
li.addEventListener('click', () => markRead(n.id)); if (!n.readAt) await markRead(n.id, false);
} const target = notificationURL(n);
if (target) window.location.assign(target);
});
li.appendChild(button);
list.appendChild(li); list.appendChild(li);
}); });
// Add "Mark all read" button // Add "Mark all read" button
const footer = document.createElement('li'); if (notifications.some(notification => !notification.readAt)) {
footer.className = 'notification-footer'; const footer = document.createElement('li');
const btn = document.createElement('button'); footer.className = 'notification-footer';
btn.textContent = 'Mark all read'; const btn = document.createElement('button');
btn.addEventListener('click', markAllRead); btn.textContent = 'Mark all read';
footer.appendChild(btn); btn.addEventListener('click', markAllRead);
list.appendChild(footer); footer.appendChild(btn);
list.appendChild(footer);
}
} }
async function markRead(id) { async function markRead(id, refresh = true) {
try { try {
await fetch(`/api/notifications/${id}/read`, { method: 'POST' }); const response = await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: 'POST' });
updateBell(); if (!response.ok) throw new Error(await response.text());
loadNotifications(); if (refresh) {
updateBell();
loadNotifications();
}
} catch (err) { } catch (err) {
console.error('mark read', err); console.error('mark read', err);
} }
@@ -108,7 +120,8 @@
async function markAllRead() { async function markAllRead() {
try { try {
await fetch('/api/notifications/read-all', { method: 'POST' }); const response = await fetch('/api/notifications/read-all', { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
updateBell(); updateBell();
loadNotifications(); loadNotifications();
} catch (err) { } catch (err) {
@@ -127,6 +140,12 @@
return d.toLocaleString(); return d.toLocaleString();
} }
function notificationURL(notification) {
if (notification.resourceType !== 'document' || !notification.resourceId) return '';
const path = notification.resourceId.replace(/^doc:/, '').split('/').map(encodeURIComponent).join('/');
return '/docs/' + path;
}
// Listen for WebSocket notification events // Listen for WebSocket notification events
if (window.__cairnquireRealtime) { if (window.__cairnquireRealtime) {
window.__cairnquireRealtime.on('notification', () => { window.__cairnquireRealtime.on('notification', () => {

View File

@@ -198,19 +198,27 @@ code {
} }
.notification-item { .notification-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.1s; transition: background 0.1s;
} }
.notification-item__button {
display: block;
width: 100%;
padding: 0.75rem 1rem;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.notification-item:hover { .notification-item:hover {
background: var(--accent-soft); background: var(--accent-soft);
} }
.notification-item.is-read { .notification-item.is-read {
opacity: 0.7; opacity: 0.7;
cursor: default;
} }
.notification-message { .notification-message {
@@ -1199,6 +1207,27 @@ code {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
} }
.comments-toolbar {
display: flex;
justify-content: flex-end;
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.comments-toolbar button,
.comment-action {
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
font: inherit;
cursor: pointer;
}
.comments-toolbar button {
font-size: 0.75rem;
}
/* Bidirectional hover highlighting */ /* Bidirectional hover highlighting */
.comments-aside-item { .comments-aside-item {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@@ -1216,6 +1245,13 @@ code {
} }
.comments-aside-item__target { .comments-aside-item__target {
display: block;
width: calc(100% + 1.2rem);
text-align: left;
font: inherit;
border-top: 0;
border-right: 0;
border-left: 0;
margin: -0.6rem -0.6rem 0.4rem; margin: -0.6rem -0.6rem 0.4rem;
padding: 0.4rem 0.6rem; padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@@ -1259,6 +1295,39 @@ code {
border-bottom: 0; border-bottom: 0;
} }
.comment-thread__replies {
margin-left: 0.7rem;
padding-left: 0.65rem;
border-left: 2px solid var(--border);
}
.comment-thread.is-resolved {
opacity: 0.7;
}
.comment-status {
padding: 0.05rem 0.3rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.68rem;
}
.comment-actions {
display: flex;
gap: 0.65rem;
margin-top: 0.3rem;
}
.comment-action {
font-size: 0.75rem;
}
.comment-action:hover,
.comments-toolbar button:hover {
text-decoration: underline;
}
.document-comments-aside__empty { .document-comments-aside__empty {
margin: 0; margin: 0;
padding: 1rem 0; padding: 1rem 0;

View File

@@ -107,6 +107,7 @@
<script src="/static/cache.js" defer></script> <script src="/static/cache.js" defer></script>
<script src="/static/sync.js" defer></script> <script src="/static/sync.js" defer></script>
<script src="/static/realtime.js" defer></script> <script src="/static/realtime.js" defer></script>
<script src="/static/notification-bell.js" defer></script>
<script src="/static/editor.js" defer></script> <script src="/static/editor.js" defer></script>
<script src="/static/auth.js" defer></script> <script src="/static/auth.js" defer></script>
<script src="/static/render.js" defer></script> <script src="/static/render.js" defer></script>

View File

@@ -4,7 +4,7 @@
<section class="workspace-shell workspace-shell--document"> <section class="workspace-shell workspace-shell--document">
<div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div> <div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div>
{{ template "browser" .Browser }} {{ template "browser" .Browser }}
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}"> <article class="document-shell" data-document-id="{{ .ID }}" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}">
<div class="document-mobile-bar"> <div class="document-mobile-bar">
<button class="document-mobile-bar__btn document-mobile-bar__btn--picker" type="button" data-toggle-picker aria-label="Open file picker" aria-expanded="false"> <button class="document-mobile-bar__btn document-mobile-bar__btn--picker" type="button" data-toggle-picker aria-label="Open file picker" aria-expanded="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg> <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg>
@@ -43,6 +43,10 @@
</svg> </svg>
</summary> </summary>
<div class="document-actions-dropdown__menu"> <div class="document-actions-dropdown__menu">
<button class="document-actions-dropdown__item" type="button" data-document-watch aria-pressed="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"></path></svg>
<span data-document-watch-label>Watch document</span>
</button>
{{ if .CanWrite }} {{ if .CanWrite }}
<a class="document-actions-dropdown__item" href="/docs/{{ trimMd .Path }}/edit"> <a class="document-actions-dropdown__item" href="/docs/{{ trimMd .Path }}/edit">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg> <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
@@ -106,6 +110,9 @@
<div class="document-comments-strip__track" data-comments-strip-track></div> <div class="document-comments-strip__track" data-comments-strip-track></div>
</div> </div>
<div class="document-comments-panel"> <div class="document-comments-panel">
<div class="comments-toolbar">
<button type="button" data-comments-history aria-pressed="false">Show resolved</button>
</div>
<div class="document-comments-panel__list" data-comments-aside-list></div> <div class="document-comments-panel__list" data-comments-aside-list></div>
</div> </div>
@@ -126,6 +133,9 @@
</button> </button>
</div> </div>
<div class="document-comments-sheet__panel"> <div class="document-comments-sheet__panel">
<div class="comments-toolbar">
<button type="button" data-comments-history aria-pressed="false">Show resolved</button>
</div>
<div class="document-comments-panel__list" data-comments-aside-list-mobile></div> <div class="document-comments-panel__list" data-comments-aside-list-mobile></div>
</div> </div>
</div> </div>

View File

@@ -13,8 +13,12 @@ services:
CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content" CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content"
CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files" CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files"
CAIRNQUIRE_PUBLIC_ORIGIN: "${CAIRNQUIRE_PUBLIC_ORIGIN:?Set CAIRNQUIRE_PUBLIC_ORIGIN in .env}" CAIRNQUIRE_PUBLIC_ORIGIN: "${CAIRNQUIRE_PUBLIC_ORIGIN:?Set CAIRNQUIRE_PUBLIC_ORIGIN in .env}"
CAIRNQUIRE_EMAIL_PROVIDER: "${CAIRNQUIRE_EMAIL_PROVIDER:-smtp}"
CAIRNQUIRE_EMAIL_SMTP_HOST: "${CAIRNQUIRE_EMAIL_SMTP_HOST:-mailpit}" CAIRNQUIRE_EMAIL_SMTP_HOST: "${CAIRNQUIRE_EMAIL_SMTP_HOST:-mailpit}"
CAIRNQUIRE_EMAIL_SMTP_PORT: "${CAIRNQUIRE_EMAIL_SMTP_PORT:-1025}" CAIRNQUIRE_EMAIL_SMTP_PORT: "${CAIRNQUIRE_EMAIL_SMTP_PORT:-1025}"
CAIRNQUIRE_EMAIL_POSTMARK_TOKEN: "${CAIRNQUIRE_EMAIL_POSTMARK_TOKEN:-}"
CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID: "${CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID:-}"
CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN: "${CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN:-}"
CAIRNQUIRE_EMAIL_FROM: "${CAIRNQUIRE_EMAIL_FROM:-notifications@cairnquire.local}" CAIRNQUIRE_EMAIL_FROM: "${CAIRNQUIRE_EMAIL_FROM:-notifications@cairnquire.local}"
CAIRNQUIRE_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}" CAIRNQUIRE_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}"
volumes: volumes: