mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Compare commits
@@ -22,3 +22,11 @@ NODE_ENV=development
|
||||
# AUTH_OIDC_CLIENT_ID=
|
||||
# AUTH_OIDC_CLIENT_SECRET=
|
||||
# AUTH_OIDC_DISPLAY_NAME=SSO
|
||||
|
||||
# --- Email Notifications (optional, auto-enabled when SMTP_HOST is set) ---
|
||||
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=
|
||||
# SMTP_PASS=
|
||||
# SMTP_FROM=Crumbs <noreply@example.com>
|
||||
|
||||
+38
-16
@@ -46,11 +46,24 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test:unit
|
||||
- run: pnpm test:unit:coverage
|
||||
|
||||
- name: Coverage summary
|
||||
if: always()
|
||||
run: bash scripts/coverage-summary.sh "Unit Test Coverage" coverage/unit/coverage-summary.json
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: unit-coverage
|
||||
path: coverage/unit/
|
||||
retention-days: 14
|
||||
|
||||
e2e-tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.0-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -65,24 +78,26 @@ jobs:
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright System Dependencies
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps chromium
|
||||
- name: Build (with coverage instrumentation)
|
||||
run: pnpm build
|
||||
env:
|
||||
DATABASE_URL: ./data/test-crumbs.db
|
||||
NODE_ENV: test
|
||||
VITE_COVERAGE: 'true'
|
||||
|
||||
- run: pnpm test:e2e
|
||||
env:
|
||||
CI: true
|
||||
VITE_COVERAGE: 'true'
|
||||
HOME: /root
|
||||
|
||||
- name: Merge E2E coverage
|
||||
if: always()
|
||||
run: npx nyc report
|
||||
|
||||
- name: Coverage summary
|
||||
if: always()
|
||||
run: bash scripts/coverage-summary.sh "E2E Test Coverage" coverage/e2e/coverage-summary.json
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
@@ -91,7 +106,14 @@ jobs:
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 30
|
||||
retention-days: 7
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-coverage
|
||||
path: coverage/e2e/
|
||||
retention-days: 14
|
||||
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
|
||||
@@ -32,6 +32,10 @@ vite.config.ts.timestamp-*
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
||||
# Coverage
|
||||
/coverage
|
||||
.nyc_output
|
||||
|
||||
# Claude Code
|
||||
/.claude/
|
||||
|
||||
@@ -40,3 +44,4 @@ todo.md
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
package-lock.json
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"report-dir": "coverage/e2e",
|
||||
"temp-dir": "coverage/e2e/raw",
|
||||
"reporter": ["text", "html", "lcov", "json-summary"],
|
||||
"include": ["src/**"],
|
||||
"exclude": ["node_modules", "tests"]
|
||||
}
|
||||
@@ -98,6 +98,10 @@ tests/
|
||||
unit/ # Vitest unit tests
|
||||
e2e/ # Playwright e2e tests (Gherkin-style)
|
||||
helpers/fixtures.ts # Shared authenticatedPage fixture
|
||||
website/ # Static landing page (crumbs.bretzel.app)
|
||||
index.html # Landing page with feature cards, screenshots, deploy section
|
||||
styles.css # Landing page styles (retro parchment theme)
|
||||
assets/ # Favicon, screenshots
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.PHONY: help dev build preview check test test-unit test-e2e lint \
|
||||
.PHONY: help dev build preview check test test-unit test-e2e \
|
||||
test-unit-coverage test-e2e-coverage lint \
|
||||
db-push db-generate db-migrate db-studio docs-api \
|
||||
install docker-build docker-up docker-down docker-logs clean \
|
||||
release release-patch release-minor release-major
|
||||
@@ -27,6 +28,12 @@ test-unit: ## Run unit tests only
|
||||
test-e2e: ## Run e2e tests only
|
||||
pnpm test:e2e
|
||||
|
||||
test-unit-coverage: ## Run unit tests with coverage
|
||||
pnpm test:unit:coverage
|
||||
|
||||
test-e2e-coverage: ## Run e2e tests with coverage
|
||||
VITE_COVERAGE=true pnpm build && pnpm test:e2e && npx nyc report
|
||||
|
||||
lint: ## Run linter
|
||||
pnpm lint
|
||||
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.11.1
|
||||
image: ghcr.io/bretzel-app/crumbs:0.20.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -11,6 +11,11 @@ services:
|
||||
- DATA_DIR=/data
|
||||
- DATABASE_URL=/data/crumbs.db
|
||||
- ORIGIN=${ORIGIN:-http://localhost:3000}
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -114,6 +114,20 @@ node build
|
||||
|
||||
Google, GitHub, and generic OIDC (Authentik, Keycloak, Okta, etc.) are supported. Providers are auto-enabled when their env vars are set. See **[AUTH.md](AUTH.md)** for environment variables, callback URLs, provider-specific setup guides, and troubleshooting.
|
||||
|
||||
### Email Notifications (optional)
|
||||
|
||||
Email notifications are sent when a note is shared with a user. Auto-enabled when `SMTP_HOST` is set.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SMTP_HOST` | — | SMTP server hostname (e.g. `smtp.example.com`) |
|
||||
| `SMTP_PORT` | `587` | SMTP port (`587` for STARTTLS, `465` for SSL) |
|
||||
| `SMTP_USER` | — | SMTP username (optional for unauthenticated relays) |
|
||||
| `SMTP_PASS` | — | SMTP password |
|
||||
| `SMTP_FROM` | `Crumbs <noreply@localhost>` | Sender address |
|
||||
|
||||
Users can opt out of share notifications in **Settings > Preferences**.
|
||||
|
||||
## Health Check
|
||||
|
||||
The Docker image includes a health check that pings `/login`:
|
||||
|
||||
+2
-1
@@ -25,8 +25,8 @@
|
||||
- Ordered lists (`1. item`)
|
||||
- Code blocks (triple backtick)
|
||||
- Inline code (single backtick)
|
||||
- Tables
|
||||
- Links — insert, edit, and remove with URL input
|
||||
- Tables (insert, add/delete rows and columns)
|
||||
- Dividers (horizontal rules)
|
||||
- Text alignment (left, center, right, justify)
|
||||
- Line breaks
|
||||
@@ -91,6 +91,7 @@
|
||||
- Share dialog accessible from editor toolbar
|
||||
- Collaborators can leave shared notes
|
||||
- Shared notes appear mixed in main list alongside owned notes
|
||||
- Email notifications when a note is shared with you (requires SMTP config, opt-out in preferences)
|
||||
|
||||
### Full-Text Search
|
||||
- Search across note titles, content, and tags
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
# E2E BDD Testing Best Practices
|
||||
|
||||
This document defines the 10 best practices for writing E2E tests in Crumbs. All tests use Playwright with Gherkin-style BDD comments.
|
||||
|
||||
## 1. Declarative scenarios, not imperative scripts
|
||||
|
||||
Describe **what** the system does, not **how** the user clicks through the UI.
|
||||
|
||||
```
|
||||
// Good: "When the user creates a note titled 'Shopping List'"
|
||||
// Bad: "When the user clicks the + button, types 'Shopping List' in the title field, and clicks save"
|
||||
```
|
||||
|
||||
If the UI changes (button moves, field renames), only the Playwright code changes — the scenario comment stays stable.
|
||||
|
||||
## 2. Given describes state, not navigation
|
||||
|
||||
`Given` sets up preconditions. It should read like a database fixture, not a user journey.
|
||||
|
||||
```
|
||||
// Good: "Given a note titled 'Old Note' exists in the trash"
|
||||
// Bad: "Given the user creates a note, then clicks trash, then confirms deletion"
|
||||
```
|
||||
|
||||
Use API calls or helpers like `createNote()` in `Given` steps to avoid coupling tests to unrelated UI flows.
|
||||
|
||||
## 3. One behavior per scenario
|
||||
|
||||
Each `test()` should verify **one outcome**. If you need multiple `Then` assertions about different behaviors, split them.
|
||||
|
||||
```ts
|
||||
// Good: Two focused scenarios
|
||||
test('Trashed note disappears from main view', ...)
|
||||
test('Trashed note appears in trash view', ...)
|
||||
|
||||
// Bad: One bloated scenario testing everything
|
||||
test('Trash workflow', ...) // creates, trashes, checks main, checks trash, restores...
|
||||
```
|
||||
|
||||
## 4. Scenario names describe outcomes, not actions
|
||||
|
||||
Name tests by the **observable result**, not the user action that triggers it.
|
||||
|
||||
```ts
|
||||
// Good: test('Archived note no longer appears in the main view')
|
||||
// Bad: test('User archives a note')
|
||||
```
|
||||
|
||||
This makes test reports read like a behavior specification.
|
||||
|
||||
## 5. Isolate test state completely
|
||||
|
||||
Each test should start from a **known, clean state**. Use API-based setup/teardown or the shared `authenticatedPage` fixture.
|
||||
|
||||
```ts
|
||||
// Use API to create/reset state before each test
|
||||
test.beforeEach(async ({ authenticatedPage: page }) => {
|
||||
await page.request.put('/api/preferences', { data: { ... } });
|
||||
});
|
||||
```
|
||||
|
||||
This prevents the "test B fails because test A didn't clean up" problem — especially important since Playwright runs workers in parallel.
|
||||
|
||||
## 6. Use `test.describe.serial` only when truly sequential
|
||||
|
||||
Reserve `serial` for flows where state genuinely cascades (setup -> login -> session). For everything else, isolate with fixtures. Treat `serial` as a **code smell** — most tests should run in **any order**.
|
||||
|
||||
## 7. Abstract repeated UI actions into helpers
|
||||
|
||||
When multiple scenarios share the same UI interaction (e.g., "create a note"), extract it into a helper at the **intent level**.
|
||||
|
||||
```ts
|
||||
// helpers/fixtures.ts
|
||||
export async function createNote(page, title, content?) { ... }
|
||||
|
||||
// In tests — reads like BDD
|
||||
await createNote(page, 'My Note', 'Hello');
|
||||
```
|
||||
|
||||
The shared `authenticatedPage` fixture and `noteCard()` locator are examples of this pattern. The `createNote()` helper abstracts the 4-step note creation flow used across most test files.
|
||||
|
||||
## 8. Apply the resilience test to every comment
|
||||
|
||||
Before committing a Gherkin comment, ask: *"Would this comment need to change if we redesigned the UI?"*
|
||||
|
||||
- If **yes** — rewrite to remove implementation details
|
||||
- If **no** — it's at the right abstraction level
|
||||
|
||||
```
|
||||
// Fails (mentions hover + button):
|
||||
// "When the user hovers the card and clicks the 3-dot menu"
|
||||
|
||||
// Passes:
|
||||
// "When the user opens the note actions menu"
|
||||
```
|
||||
|
||||
## 9. Prefer `await expect(...).toBeVisible()` over arbitrary waits
|
||||
|
||||
Playwright's auto-waiting is powerful. Never use `page.waitForTimeout()` — it's flaky and slow. Assert on the **expected end state** instead.
|
||||
|
||||
```ts
|
||||
// Good: Waits intelligently for the element
|
||||
await expect(page.getByText('Note saved')).toBeVisible();
|
||||
|
||||
// Bad: Arbitrary delay
|
||||
await page.waitForTimeout(2000);
|
||||
```
|
||||
|
||||
The exception is `waitForLoadState('networkidle')` after authentication redirects, where the full page reload makes auto-waiting insufficient.
|
||||
|
||||
## 10. Structure tests in the Given/When/Then rhythm
|
||||
|
||||
Use comments to maintain the three-act structure. This makes tests scannable and self-documenting.
|
||||
|
||||
```ts
|
||||
test('Pinned note appears at the top of the list', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Important" exists
|
||||
await createNote(page, 'Important');
|
||||
|
||||
// When the user pins the note
|
||||
await pinNote(page, 'Important');
|
||||
|
||||
// Then it appears before unpinned notes
|
||||
const firstCard = page.locator('[data-testid="note-card"]').first();
|
||||
await expect(firstCard).toContainText('Important');
|
||||
});
|
||||
```
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Practice | Category | Goal |
|
||||
|----------|----------|------|
|
||||
| 1. Declarative scenarios | Language | Stable comments despite UI changes |
|
||||
| 2. Given = state | Language | Decouple setup from UI flow |
|
||||
| 3. One behavior per test | Structure | Focused, debuggable tests |
|
||||
| 4. Outcome-based names | Language | Test reports read as specs |
|
||||
| 5. Isolate state | Reliability | No cross-test pollution |
|
||||
| 6. Avoid serial | Reliability | Parallel-safe by default |
|
||||
| 7. Extract helpers | Structure | DRY, intent-level code |
|
||||
| 8. Resilience test | Language | UI-change-proof comments |
|
||||
| 9. Auto-wait assertions | Reliability | No flaky timeouts |
|
||||
| 10. Given/When/Then | Structure | Scannable three-act tests |
|
||||
@@ -0,0 +1,114 @@
|
||||
CREATE TABLE `api_keys` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`key_hash` text NOT NULL,
|
||||
`key_prefix` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`last_used_at` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `note_collaborators` (
|
||||
`note_id` text NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`added_by` integer NOT NULL,
|
||||
`added_at` integer NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `note_collaborators_unique` ON `note_collaborators` (`note_id`,`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `note_collaborators_user_id_idx` ON `note_collaborators` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `note_user_state` (
|
||||
`note_id` text NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`pinned` integer DEFAULT false NOT NULL,
|
||||
`archived` integer DEFAULT false NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `note_user_state_unique` ON `note_user_state` (`note_id`,`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `user_preferences` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`key` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_preferences_user_key_unique` ON `user_preferences` (`user_id`,`key`);--> statement-breakpoint
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_attachments` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer DEFAULT 0 NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`mime_type` text NOT NULL,
|
||||
`size` integer NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`thumbnail_path` text,
|
||||
`featured` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_attachments`("id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at") SELECT "id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at" FROM `attachments`;--> statement-breakpoint
|
||||
DROP TABLE `attachments`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_attachments` RENAME TO `attachments`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE TABLE `__new_notes` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer DEFAULT 0 NOT NULL,
|
||||
`title` text DEFAULT '' NOT NULL,
|
||||
`content` text DEFAULT '' NOT NULL,
|
||||
`color` text DEFAULT 'default' NOT NULL,
|
||||
`pinned` integer DEFAULT false NOT NULL,
|
||||
`archived` integer DEFAULT false NOT NULL,
|
||||
`trashed` integer DEFAULT false NOT NULL,
|
||||
`trashed_at` integer,
|
||||
`checklist_mode` integer DEFAULT false NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`version` integer DEFAULT 1 NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_notes`("id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version") SELECT "id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version" FROM `notes`;--> statement-breakpoint
|
||||
DROP TABLE `notes`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_notes` RENAME TO `notes`;--> statement-breakpoint
|
||||
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
|
||||
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
|
||||
CREATE TABLE `__new_sync_log` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer DEFAULT 0 NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`operation` text NOT NULL,
|
||||
`timestamp` integer NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_sync_log`("id", "user_id", "note_id", "operation", "timestamp", "client_id") SELECT "id", "user_id", "note_id", "operation", "timestamp", "client_id" FROM `sync_log`;--> statement-breakpoint
|
||||
DROP TABLE `sync_log`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_sync_log` RENAME TO `sync_log`;--> statement-breakpoint
|
||||
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
|
||||
CREATE TABLE `__new_tags` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer DEFAULT 0 NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_tags`("id", "user_id", "name") SELECT "id", "user_id", "name" FROM `tags`;--> statement-breakpoint
|
||||
DROP TABLE `tags`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_tags` RENAME TO `tags`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1773016788899,
|
||||
"tag": "0001_strange_lord_hawal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1773150888301,
|
||||
"tag": "0002_deep_black_widow",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+13070
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.12.0",
|
||||
"version": "0.21.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
@@ -19,7 +19,10 @@
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:unit:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:coverage": "VITE_COVERAGE=true pnpm build && pnpm test:e2e && nyc report",
|
||||
"coverage:merge-e2e": "nyc merge coverage/e2e/raw coverage/e2e/merged.json && nyc report",
|
||||
"test": "pnpm test:unit && pnpm test:e2e",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
@@ -34,6 +37,10 @@
|
||||
"@tiptap/core": "^3.20.1",
|
||||
"@tiptap/extension-link": "^3.20.1",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/extension-table": "^3.20.1",
|
||||
"@tiptap/extension-table-cell": "^3.20.1",
|
||||
"@tiptap/extension-table-header": "^3.20.1",
|
||||
"@tiptap/extension-table-row": "^3.20.1",
|
||||
"@tiptap/extension-task-item": "^3.20.1",
|
||||
"@tiptap/extension-task-list": "^3.20.1",
|
||||
"@tiptap/extension-text-align": "^3.20.1",
|
||||
@@ -47,6 +54,7 @@
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"nodemailer": "^8.0.2",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.1.0",
|
||||
@@ -61,16 +69,20 @@
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"nyc": "^18.0.0",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
"vitest": "^3.2.4",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,8 @@ export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
retries: 0,
|
||||
workers: process.env.CI ? 4 : undefined,
|
||||
reporter: 'html',
|
||||
globalSetup: './tests/e2e/global-setup.ts',
|
||||
use: {
|
||||
@@ -27,7 +28,7 @@ export default defineConfig({
|
||||
}
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm build && pnpm preview --port 4173',
|
||||
command: 'pnpm preview --port 4173',
|
||||
port: 4173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
|
||||
Generated
+994
File diff suppressed because it is too large
Load Diff
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Writes a coverage summary table to $GITHUB_STEP_SUMMARY (or stdout if not in CI).
|
||||
# Usage: scripts/coverage-summary.sh <title> <json-summary-path>
|
||||
set -euo pipefail
|
||||
|
||||
TITLE="${1:?Usage: coverage-summary.sh <title> <json-summary-path>}"
|
||||
JSON="${2:?Usage: coverage-summary.sh <title> <json-summary-path>}"
|
||||
|
||||
if [ ! -f "$JSON" ]; then
|
||||
echo "Coverage summary not found: $JSON" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Resolve to absolute path for require()
|
||||
ABS_JSON="$(cd "$(dirname "$JSON")" && pwd)/$(basename "$JSON")"
|
||||
|
||||
# Extract totals with node (available in CI, no jq dependency)
|
||||
read -r stmts branches funcs lines < <(node -e "
|
||||
const s = require('$ABS_JSON').total;
|
||||
console.log(s.statements.pct, s.branches.pct, s.functions.pct, s.lines.pct);
|
||||
")
|
||||
|
||||
TABLE="## $TITLE
|
||||
|
||||
| Metric | Coverage |
|
||||
|--------|----------|
|
||||
| Statements | ${stmts}% |
|
||||
| Branches | ${branches}% |
|
||||
| Functions | ${funcs}% |
|
||||
| Lines | ${lines}% |
|
||||
"
|
||||
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
echo "$TABLE" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "$TABLE"
|
||||
fi
|
||||
+104
@@ -72,3 +72,107 @@ body::before {
|
||||
.prose :where(a):not(:where([class~="not-prose"] *)):hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Table styles for TipTap editor */
|
||||
.tiptap table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tiptap th,
|
||||
.tiptap td {
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: 0.375rem 0.625rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
min-width: 4rem;
|
||||
}
|
||||
|
||||
.tiptap th {
|
||||
font-weight: 600;
|
||||
background-color: color-mix(in srgb, var(--bg-base) 60%, transparent);
|
||||
}
|
||||
|
||||
.tiptap td p,
|
||||
.tiptap th p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tiptap .selectedCell {
|
||||
background-color: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Task list styles — shared between TipTap editor and markdown-it preview */
|
||||
|
||||
/* TipTap: ul[data-type="taskList"] | markdown-it: ul.task-list */
|
||||
.tiptap ul[data-type="taskList"],
|
||||
.prose ul.task-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li,
|
||||
.prose ul.task-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li::marker,
|
||||
.prose ul.task-list li::marker {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* TipTap: checkbox is inside a label */
|
||||
.tiptap ul[data-type="taskList"] li label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Shared checkbox styles */
|
||||
.tiptap ul[data-type="taskList"] li label input[type="checkbox"],
|
||||
.prose ul.task-list .task-checkbox {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* TipTap: text content is in a div>p */
|
||||
.tiptap ul[data-type="taskList"] li > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li > div p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Checked item styling */
|
||||
.tiptap ul[data-type="taskList"] li[data-checked="true"] > div {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Override prose spacing for task lists */
|
||||
.prose ul[data-type="taskList"],
|
||||
.prose ul.task-list {
|
||||
padding-left: 0;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.prose ul[data-type="taskList"] li,
|
||||
.prose ul.task-list li {
|
||||
padding-left: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose ul.task-list li p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
+19
-24
@@ -22,36 +22,31 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// MCP endpoint: authenticate via Bearer token (API key)
|
||||
if (pathname.startsWith('/api/mcp')) {
|
||||
// API routes: authenticate via Bearer token if present
|
||||
if (pathname.startsWith('/api/')) {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
if (token) {
|
||||
const { valid, userId } = validateApiKey(token);
|
||||
if (!valid || !userId) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { valid, userId } = validateApiKey(token);
|
||||
if (!valid || !userId) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
const user = getUserForApiKey(userId);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const user = getUserForApiKey(userId);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
event.locals.user = user;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
event.locals.user = user;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// Check if setup is complete
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
class="space-y-0.5"
|
||||
>
|
||||
{#each activeItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div class="group flex items-center gap-2 py-1.5" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
|
||||
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
|
||||
onkeydown={(e) => handleKeydown(e, item.id)}
|
||||
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
class="flex-1 min-w-0 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
/>
|
||||
@@ -155,7 +155,7 @@
|
||||
|
||||
<button
|
||||
onclick={() => addItem(items.length - 1)}
|
||||
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
class="flex items-center gap-2 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
data-testid="checklist-add"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
@@ -176,7 +176,7 @@
|
||||
{#if doneExpanded}
|
||||
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
|
||||
{#each doneItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1">
|
||||
<div class="group flex items-center gap-2 py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
{#if showPopover}
|
||||
<div
|
||||
class="absolute left-0 bottom-full z-20 mb-1 min-w-[160px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
|
||||
class="absolute right-0 bottom-full z-20 mb-1 min-w-[160px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
|
||||
data-testid="collaborator-popover"
|
||||
>
|
||||
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
Heading,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
TextQuote,
|
||||
CodeXml,
|
||||
Bold,
|
||||
@@ -23,7 +24,10 @@
|
||||
AlignJustify,
|
||||
Minus,
|
||||
ChevronDown,
|
||||
Ellipsis
|
||||
Table2,
|
||||
BetweenHorizontalEnd,
|
||||
BetweenVerticalEnd,
|
||||
RemoveFormatting
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -35,6 +39,9 @@
|
||||
|
||||
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
|
||||
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
|
||||
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
|
||||
|
||||
let openDropdown: string | null = $state(null);
|
||||
let linkUrl: string = $state('');
|
||||
@@ -110,13 +117,35 @@
|
||||
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="flex items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
||||
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
||||
data-testid="formatting-toolbar"
|
||||
>
|
||||
<!-- Undo/Redo -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().undo().run()}
|
||||
disabled={!canUndo()}
|
||||
class={btnClass(false, !canUndo())}
|
||||
title="Undo"
|
||||
data-testid="format-undo"
|
||||
>
|
||||
<Undo2 size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().redo().run()}
|
||||
disabled={!canRedo()}
|
||||
class={btnClass(false, !canRedo())}
|
||||
title="Redo"
|
||||
data-testid="format-redo"
|
||||
>
|
||||
<Redo2 size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Inline -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||
class={btnClass(editor?.isActive('bold') ?? false)}
|
||||
class={btnClass(isActive('bold'))}
|
||||
title="Bold (Ctrl+B)"
|
||||
data-testid="format-bold"
|
||||
>
|
||||
@@ -124,7 +153,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
class={btnClass(editor?.isActive('italic') ?? false)}
|
||||
class={btnClass(isActive('italic'))}
|
||||
title="Italic (Ctrl+I)"
|
||||
data-testid="format-italic"
|
||||
>
|
||||
@@ -132,7 +161,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
class={btnClass(editor?.isActive('strike') ?? false)}
|
||||
class={btnClass(isActive('strike'))}
|
||||
title="Strikethrough (Ctrl+Shift+X)"
|
||||
data-testid="format-strikethrough"
|
||||
>
|
||||
@@ -140,7 +169,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
class={btnClass(editor?.isActive('underline') ?? false)}
|
||||
class={btnClass(isActive('underline'))}
|
||||
title="Underline (Ctrl+U)"
|
||||
data-testid="format-underline"
|
||||
>
|
||||
@@ -153,7 +182,7 @@
|
||||
<div class="relative" data-dropdown="heading">
|
||||
<button
|
||||
onclick={() => toggleDropdown('heading')}
|
||||
class={dropdownBtnClass(editor?.isActive('heading') ?? false)}
|
||||
class={dropdownBtnClass(isActive('heading'))}
|
||||
title="Heading"
|
||||
data-testid="format-heading"
|
||||
>
|
||||
@@ -164,7 +193,7 @@
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
|
||||
class={dropdownItemClass(isActive('heading', { level: 1 }))}
|
||||
data-testid="format-h1"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
|
||||
@@ -172,7 +201,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 2 }) ?? false)}
|
||||
class={dropdownItemClass(isActive('heading', { level: 2 }))}
|
||||
data-testid="format-h2"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
|
||||
@@ -180,7 +209,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 3 }) ?? false)}
|
||||
class={dropdownItemClass(isActive('heading', { level: 3 }))}
|
||||
data-testid="format-h3"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
|
||||
@@ -194,7 +223,7 @@
|
||||
<div class="relative" data-dropdown="list">
|
||||
<button
|
||||
onclick={() => toggleDropdown('list')}
|
||||
class={dropdownBtnClass((editor?.isActive('bulletList') || editor?.isActive('orderedList')) ?? false)}
|
||||
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
|
||||
title="Lists"
|
||||
data-testid="format-list"
|
||||
>
|
||||
@@ -205,7 +234,7 @@
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
|
||||
class={dropdownItemClass(isActive('bulletList'))}
|
||||
data-testid="format-bullet-list"
|
||||
>
|
||||
<List size={iconSize} />
|
||||
@@ -213,12 +242,20 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('orderedList') ?? false)}
|
||||
class={dropdownItemClass(isActive('orderedList'))}
|
||||
data-testid="format-ordered-list"
|
||||
>
|
||||
<ListOrdered size={iconSize} />
|
||||
<span>Ordered list</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('taskList'))}
|
||||
data-testid="format-task-list"
|
||||
>
|
||||
<ListChecks size={iconSize} />
|
||||
<span>Task list</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -227,7 +264,7 @@
|
||||
<div class="relative" data-dropdown="link">
|
||||
<button
|
||||
onclick={() => toggleDropdown('link')}
|
||||
class={btnClass(editor?.isActive('link') ?? false)}
|
||||
class={btnClass(isActive('link'))}
|
||||
title="Insert link (Ctrl+K)"
|
||||
data-testid="format-link"
|
||||
>
|
||||
@@ -259,7 +296,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={openLink}
|
||||
disabled={!editor?.getAttributes('link').href}
|
||||
disabled={!getAttrs('link').href}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
||||
title="Open link"
|
||||
data-testid="format-link-open"
|
||||
@@ -269,7 +306,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={removeLink}
|
||||
disabled={!editor?.isActive('link')}
|
||||
disabled={!isActive('link')}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
|
||||
title="Remove link"
|
||||
data-testid="format-unlink"
|
||||
@@ -281,99 +318,123 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- More dropdown -->
|
||||
<div class="relative" data-dropdown="more">
|
||||
<!-- Table dropdown -->
|
||||
<div class="relative" data-dropdown="table">
|
||||
<button
|
||||
onclick={() => toggleDropdown('more')}
|
||||
class={dropdownBtnClass(false)}
|
||||
title="More options"
|
||||
data-testid="format-more"
|
||||
onclick={() => toggleDropdown('table')}
|
||||
class={dropdownBtnClass(isActive('table'))}
|
||||
title="Table"
|
||||
data-testid="format-table"
|
||||
>
|
||||
<Ellipsis size={iconSize} />
|
||||
<Table2 size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'more'}
|
||||
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
{#if openDropdown === 'table'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().undo().run(); closeDropdowns(); }}
|
||||
disabled={!canUndo()}
|
||||
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-undo"
|
||||
data-testid="format-table-insert"
|
||||
>
|
||||
<Undo2 size={iconSize} />
|
||||
<span>Undo</span>
|
||||
<Table2 size={iconSize} />
|
||||
<span>Insert table</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().redo().run(); closeDropdowns(); }}
|
||||
disabled={!canRedo()}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-redo"
|
||||
>
|
||||
<Redo2 size={iconSize} />
|
||||
<span>Redo</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleCode().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('code') ?? false)}
|
||||
data-testid="format-code"
|
||||
>
|
||||
<Code size={iconSize} />
|
||||
<span>Inline code</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBlockquote().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('blockquote') ?? false)}
|
||||
data-testid="format-blockquote"
|
||||
>
|
||||
<TextQuote size={iconSize} />
|
||||
<span>Blockquote</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleCodeBlock().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('codeBlock') ?? false)}
|
||||
data-testid="format-code-block"
|
||||
>
|
||||
<CodeXml size={iconSize} />
|
||||
<span>Code block</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setHorizontalRule().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Divider</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
{#if isActive('table')}
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-add-row"
|
||||
>
|
||||
<BetweenHorizontalEnd size={iconSize} />
|
||||
<span>Add row</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-add-col"
|
||||
>
|
||||
<BetweenVerticalEnd size={iconSize} />
|
||||
<span>Add column</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-delete-row"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete row</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-delete-col"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete column</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
|
||||
class={`${dropdownItemClass()} text-[var(--destructive)]`}
|
||||
data-testid="format-table-delete"
|
||||
>
|
||||
<RemoveFormatting size={iconSize} />
|
||||
<span>Delete table</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Alignment dropdown -->
|
||||
<div class="relative" data-dropdown="align">
|
||||
<button
|
||||
onclick={() => toggleDropdown('align')}
|
||||
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
|
||||
title="Text alignment"
|
||||
data-testid="format-align"
|
||||
>
|
||||
{#if isActive({ textAlign: 'center' })}
|
||||
<AlignCenter size={iconSize} />
|
||||
{:else if isActive({ textAlign: 'right' })}
|
||||
<AlignRight size={iconSize} />
|
||||
{:else if isActive({ textAlign: 'justify' })}
|
||||
<AlignJustify size={iconSize} />
|
||||
{:else}
|
||||
<AlignLeft size={iconSize} />
|
||||
{/if}
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'align'}
|
||||
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
|
||||
data-testid="format-align-left"
|
||||
>
|
||||
<AlignLeft size={iconSize} />
|
||||
<span>Align left</span>
|
||||
<span>Left</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'center' }) ?? false)}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
|
||||
data-testid="format-align-center"
|
||||
>
|
||||
<AlignCenter size={iconSize} />
|
||||
<span>Align center</span>
|
||||
<span>Center</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'right' }) ?? false)}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
|
||||
data-testid="format-align-right"
|
||||
>
|
||||
<AlignRight size={iconSize} />
|
||||
<span>Align right</span>
|
||||
<span>Right</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'justify' }) ?? false)}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
|
||||
data-testid="format-align-justify"
|
||||
>
|
||||
<AlignJustify size={iconSize} />
|
||||
@@ -382,4 +443,40 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Block-level formatting -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleCode().run()}
|
||||
class={btnClass(isActive('code'))}
|
||||
title="Inline code"
|
||||
data-testid="format-code"
|
||||
>
|
||||
<Code size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
class={btnClass(isActive('blockquote'))}
|
||||
title="Blockquote"
|
||||
data-testid="format-blockquote"
|
||||
>
|
||||
<TextQuote size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
class={btnClass(isActive('codeBlock'))}
|
||||
title="Code block"
|
||||
data-testid="format-code-block"
|
||||
>
|
||||
<CodeXml size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
|
||||
class={btnClass()}
|
||||
title="Divider"
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
let { open, onClose, appVersion }: Props = $props();
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
function closeMobile() {
|
||||
if (window.matchMedia('(max-width: 1023px)').matches) {
|
||||
@@ -75,17 +74,12 @@
|
||||
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)]">
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex w-full items-center gap-3 px-6 py-3 text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--primary)]"
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$page.url.pathname.startsWith('/settings') ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
onclick={closeMobile}
|
||||
data-testid="settings-link"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<Settings size={20} />
|
||||
Settings
|
||||
</a>
|
||||
{#if appVersion}
|
||||
<div class="border-t border-[var(--border-subtle)] px-6 py-2 text-right text-xs text-[var(--text-muted)]">
|
||||
v{appVersion}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import TagChip from './TagChip.svelte';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import CollaboratorPopover from './CollaboratorPopover.svelte';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
@@ -139,17 +138,17 @@
|
||||
{/if}
|
||||
|
||||
{#if note.checklistMode && checklistItems.length > 0}
|
||||
<ul class="space-y-1" data-testid="note-checklist-preview">
|
||||
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
|
||||
{#each sortedChecklistItems.slice(0, 8) as item}
|
||||
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
|
||||
<li class="flex items-start gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
|
||||
class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
|
||||
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
|
||||
data-testid="card-checklist-checkbox"
|
||||
/>
|
||||
<span class="truncate">{item.text}</span>
|
||||
<span class="break-words min-w-0">{item.text}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{#if checklistItems.length > 8}
|
||||
@@ -162,14 +161,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if note.tags && note.tags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each note.tags as tag}
|
||||
<TagChip {tag} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons - show on hover -->
|
||||
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
|
||||
{#if $currentFilter === 'trashed'}
|
||||
@@ -246,24 +237,3 @@
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
article :global(.prose li:has(.task-checkbox)) {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
article :global(.prose li:has(.task-checkbox))::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
article :global(.prose li:has(.task-checkbox))::marker {
|
||||
content: none;
|
||||
}
|
||||
|
||||
article :global(.task-checkbox) {
|
||||
margin-top: 0.15rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
import TiptapEditor from './TiptapEditor.svelte';
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
import ShareDialog from './ShareDialog.svelte';
|
||||
import NoteHistory from './NoteHistory.svelte';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { notes } from '$lib/stores/notes.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
@@ -18,6 +20,7 @@
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
import UserPlus from 'lucide-svelte/icons/user-plus';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
import History from 'lucide-svelte/icons/history';
|
||||
|
||||
interface Props {
|
||||
note: Note | null;
|
||||
@@ -26,19 +29,20 @@
|
||||
}
|
||||
|
||||
const { note, isNew = false, onClose }: Props = $props();
|
||||
const prefs = getPreferences();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let title = $state(note?.title ?? '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let content = $state(note?.content ?? '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let color = $state<NoteColor>(note?.color ?? 'default');
|
||||
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let checklistMode = $state(note?.checklistMode ?? false);
|
||||
let showColorPicker = $state(false);
|
||||
let showImageUpload = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let rawMarkdownMode = $state(note?.checklistMode ?? false);
|
||||
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
let tiptapEditor: Editor | undefined = $state();
|
||||
let editorTick = $state(0);
|
||||
@@ -53,6 +57,7 @@
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
let showShareDialog = $state(false);
|
||||
let showHistory = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
|
||||
const isOwner = $derived(note?.isOwner !== false);
|
||||
@@ -82,6 +87,37 @@
|
||||
showShareDialog = !showShareDialog;
|
||||
}
|
||||
|
||||
async function toggleHistory() {
|
||||
if (currentlyNew) {
|
||||
const id = await autoSave();
|
||||
if (!id) return;
|
||||
}
|
||||
showHistory = !showHistory;
|
||||
}
|
||||
|
||||
async function handleHistoryRestored() {
|
||||
if (!noteId) return;
|
||||
// Reload the note content from the server after a restore
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}`);
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
title = updated.title ?? '';
|
||||
content = updated.content ?? '';
|
||||
color = updated.color ?? 'default';
|
||||
checklistMode = updated.checklistMode ?? false;
|
||||
// Bump editor tick to force TiptapEditor to re-render
|
||||
editorTick++;
|
||||
// Update the store too
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, ...updated } : n))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// failed silently
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||
@@ -134,6 +170,38 @@
|
||||
bgStyle = `background-color: ${colors.bg}`;
|
||||
});
|
||||
|
||||
// Track whether mousedown started on the overlay (not inside the editor).
|
||||
// Prevents closing when the user selects text inside the editor and
|
||||
// releases the mouse outside — the click event fires on the overlay,
|
||||
// but we only want to close if the press also started there.
|
||||
let mousedownOnOverlay = false;
|
||||
|
||||
// On mobile, the tap that opens the editor produces synthetic mouse events
|
||||
// (mousedown → mouseup → click) at the original touch coordinates. If those
|
||||
// coordinates land on the overlay backdrop, the editor would close instantly.
|
||||
// Guard against this by ignoring mousedowns that arrive within the first
|
||||
// animation frame after mount.
|
||||
let overlayReady = false;
|
||||
$effect(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
overlayReady = true;
|
||||
});
|
||||
return () => {
|
||||
overlayReady = false;
|
||||
cancelAnimationFrame(id);
|
||||
};
|
||||
});
|
||||
|
||||
function handleOverlayMousedown(e: MouseEvent) {
|
||||
if (!overlayReady) return;
|
||||
mousedownOnOverlay = e.target === e.currentTarget;
|
||||
}
|
||||
|
||||
function handleOverlayClick() {
|
||||
if (mousedownOnOverlay) saveAndClose();
|
||||
mousedownOnOverlay = false;
|
||||
}
|
||||
|
||||
async function saveAndClose() {
|
||||
if (!title.trim() && !content.trim()) {
|
||||
onClose();
|
||||
@@ -195,13 +263,14 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
onclick={saveAndClose}
|
||||
onmousedown={handleOverlayMousedown}
|
||||
onclick={handleOverlayClick}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 flex w-full max-w-xl flex-col rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
style={bgStyle}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
||||
@@ -214,6 +283,13 @@
|
||||
bind:value={title}
|
||||
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="note-title-input"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (textareaEl) textareaEl.focus();
|
||||
else if (tiptapEditor) tiptapEditor.commands.focus('start');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
@@ -339,6 +415,18 @@
|
||||
<Users class="h-5 w-5" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- History button (only for saved notes) -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={toggleHistory}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Version history"
|
||||
data-testid="history-toggle"
|
||||
>
|
||||
<History class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -361,3 +449,11 @@
|
||||
onUpdate={handleCollaboratorsUpdate}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showHistory && noteId}
|
||||
<NoteHistory
|
||||
noteId={noteId}
|
||||
onClose={() => (showHistory = false)}
|
||||
onRestored={handleHistoryRestored}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
|
||||
data-testid="note-grid"
|
||||
use:dndzone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {} }}
|
||||
use:dndzone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {}, delayTouchStart: 400 }}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import type { NoteVersionSummary, NoteVersion } from '$lib/types/index.js';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import RotateCcw from 'lucide-svelte/icons/rotate-ccw';
|
||||
import Clock from 'lucide-svelte/icons/clock';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
onClose: () => void;
|
||||
onRestored: () => void;
|
||||
}
|
||||
|
||||
const { noteId, onClose, onRestored }: Props = $props();
|
||||
|
||||
let versions = $state<NoteVersionSummary[]>([]);
|
||||
let selectedVersion = $state<NoteVersion | null>(null);
|
||||
let isLoading = $state(true);
|
||||
let isLoadingVersion = $state(false);
|
||||
let isRestoring = $state(false);
|
||||
let showConfirm = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Track noteId explicitly so this only re-runs when the note changes
|
||||
noteId;
|
||||
void loadVersions();
|
||||
});
|
||||
|
||||
async function loadVersions() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/versions`);
|
||||
if (res.ok) {
|
||||
versions = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// failed silently
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVersion(versionId: string) {
|
||||
isLoadingVersion = true;
|
||||
selectedVersion = null;
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/versions/${versionId}`);
|
||||
if (res.ok) {
|
||||
selectedVersion = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// failed silently
|
||||
} finally {
|
||||
isLoadingVersion = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreVersion() {
|
||||
if (!selectedVersion) return;
|
||||
isRestoring = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/notes/${noteId}/versions/${selectedVersion.id}/restore`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (res.ok) {
|
||||
showToast('Version restored successfully', 'success');
|
||||
onRestored();
|
||||
onClose();
|
||||
} else {
|
||||
showToast('Failed to restore version', 'error');
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to restore version', 'error');
|
||||
} finally {
|
||||
isRestoring = false;
|
||||
showConfirm = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10"
|
||||
onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
data-testid="history-overlay"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 flex w-full max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
data-testid="history-panel"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Clock class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<h2 class="text-sm font-semibold text-[var(--text)]">Version History</h2>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
|
||||
title="Close history"
|
||||
data-testid="close-history-btn"
|
||||
>
|
||||
<X class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 max-h-[60vh] flex-1">
|
||||
<!-- Version list -->
|
||||
<div class="w-56 flex-none border-r border-[var(--border-subtle)] overflow-y-auto">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
|
||||
Loading…
|
||||
</div>
|
||||
{:else if versions.length === 0}
|
||||
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
|
||||
No history yet
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-[var(--border-subtle)]">
|
||||
{#each versions as v (v.id)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full px-3 py-3 text-left hover:bg-[var(--border)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
|
||||
onclick={() => selectVersion(v.id)}
|
||||
data-testid="version-item"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<span class="text-xs font-semibold text-[var(--primary)]">v{v.version}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-[var(--text-muted)]">{formatDate(v.createdAt)}</div>
|
||||
{#if v.title}
|
||||
<div class="mt-1 truncate text-xs font-medium text-[var(--text)]">{v.title}</div>
|
||||
{/if}
|
||||
{#if v.contentPreview}
|
||||
<div class="mt-0.5 line-clamp-2 text-xs text-[var(--text-muted)]">{v.contentPreview}</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Version preview -->
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
{#if isLoadingVersion}
|
||||
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
|
||||
Loading…
|
||||
</div>
|
||||
{:else if selectedVersion}
|
||||
<div class="flex flex-1 flex-col overflow-y-auto">
|
||||
<div class="flex-1 overflow-y-auto px-4 py-3">
|
||||
{#if selectedVersion.title}
|
||||
<h3 class="mb-2 text-base font-semibold text-[var(--text)]">{selectedVersion.title}</h3>
|
||||
{/if}
|
||||
<pre class="whitespace-pre-wrap text-sm text-[var(--text)] font-[inherit]">{selectedVersion.content || '(empty)'}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Restore footer -->
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-3">
|
||||
{#if showConfirm}
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs text-[var(--text-muted)]">
|
||||
This will replace the current note. The current version will be saved as a snapshot.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={restoreVersion}
|
||||
disabled={isRestoring}
|
||||
class="flex items-center gap-1.5 rounded-sm border border-[var(--destructive)] bg-[var(--destructive)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||
data-testid="confirm-restore-btn"
|
||||
>
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
{isRestoring ? 'Restoring…' : 'Confirm Restore'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showConfirm = false)}
|
||||
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (showConfirm = true)}
|
||||
class="flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)] transition-colors"
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
Restore this version
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
|
||||
Select a version to preview
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,6 +6,12 @@
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { Table } from '@tiptap/extension-table';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
|
||||
interface Props {
|
||||
@@ -30,6 +36,12 @@
|
||||
Underline,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Markdown
|
||||
],
|
||||
content,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteCollaborators, users } from './db/schema.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId } from './api-utils.js';
|
||||
|
||||
let db: Db;
|
||||
const OWNER_ID = 1;
|
||||
const COLLAB_ID = 2;
|
||||
const STRANGER_ID = 3;
|
||||
|
||||
function seedUsers() {
|
||||
db.insert(users)
|
||||
.values([
|
||||
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'stranger@test.com', displayName: 'Stranger', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string) {
|
||||
db.insert(notes)
|
||||
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
function shareNote(noteId: string) {
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId, userId: COLLAB_ID, addedBy: OWNER_ID, addedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUsers();
|
||||
});
|
||||
|
||||
describe('canAccessNote', () => {
|
||||
it('should allow owner to access their note', () => {
|
||||
seedNote('n1');
|
||||
const result = canAccessNote(db, 'n1', OWNER_ID);
|
||||
expect(result).toEqual({ canAccess: true, isOwner: true });
|
||||
});
|
||||
|
||||
it('should allow collaborator to access shared note', () => {
|
||||
seedNote('n1');
|
||||
shareNote('n1');
|
||||
const result = canAccessNote(db, 'n1', COLLAB_ID);
|
||||
expect(result).toEqual({ canAccess: true, isOwner: false });
|
||||
});
|
||||
|
||||
it('should deny non-collaborator access', () => {
|
||||
seedNote('n1');
|
||||
const result = canAccessNote(db, 'n1', STRANGER_ID);
|
||||
expect(result).toEqual({ canAccess: false, isOwner: false });
|
||||
});
|
||||
|
||||
it('should return false for non-existent note', () => {
|
||||
const result = canAccessNote(db, 'nonexistent', OWNER_ID);
|
||||
expect(result).toEqual({ canAccess: false, isOwner: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireNoteAccess', () => {
|
||||
it('should throw 404 for unauthorized user', () => {
|
||||
seedNote('n1');
|
||||
expect(() => requireNoteAccess(db, 'n1', STRANGER_ID)).toThrow();
|
||||
});
|
||||
|
||||
it('should return isOwner for authorized owner', () => {
|
||||
seedNote('n1');
|
||||
expect(requireNoteAccess(db, 'n1', OWNER_ID)).toEqual({ isOwner: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireNoteOwnership', () => {
|
||||
it('should throw 404 for collaborator', () => {
|
||||
seedNote('n1');
|
||||
shareNote('n1');
|
||||
expect(() => requireNoteOwnership(db, 'n1', COLLAB_ID)).toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for owner', () => {
|
||||
seedNote('n1');
|
||||
expect(() => requireNoteOwnership(db, 'n1', OWNER_ID)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserId', () => {
|
||||
it('should throw 401 when no user in locals', () => {
|
||||
expect(() => getUserId({ locals: {} as App.Locals })).toThrow();
|
||||
});
|
||||
|
||||
it('should return user id from locals', () => {
|
||||
const id = getUserId({ locals: { user: { id: 42, role: 'user', email: '', displayName: '' } } as App.Locals });
|
||||
expect(id).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { db } from './db/index.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { notes, noteCollaborators } from './db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
@@ -21,7 +21,7 @@ export function requireAdmin(event: EventWithLocals) {
|
||||
/**
|
||||
* Check if a user can access a note (as owner or collaborator).
|
||||
*/
|
||||
export function canAccessNote(noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
|
||||
export function canAccessNote(db: Db, noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
|
||||
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (!note) return { canAccess: false, isOwner: false };
|
||||
if (note.userId === userId) return { canAccess: true, isOwner: true };
|
||||
@@ -37,8 +37,8 @@ export function canAccessNote(noteId: string, userId: number): { canAccess: bool
|
||||
/**
|
||||
* Require that a user can access a note. Throws 404 if no access.
|
||||
*/
|
||||
export function requireNoteAccess(noteId: string, userId: number): { isOwner: boolean } {
|
||||
const { canAccess, isOwner } = canAccessNote(noteId, userId);
|
||||
export function requireNoteAccess(db: Db, noteId: string, userId: number): { isOwner: boolean } {
|
||||
const { canAccess, isOwner } = canAccessNote(db, noteId, userId);
|
||||
if (!canAccess) throw error(404, 'Not found');
|
||||
return { isOwner };
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function requireNoteAccess(noteId: string, userId: number): { isOwner: bo
|
||||
/**
|
||||
* Require that a user is the owner of a note. Throws 404 if not owner.
|
||||
*/
|
||||
export function requireNoteOwnership(noteId: string, userId: number): void {
|
||||
export function requireNoteOwnership(db: Db, noteId: string, userId: number): void {
|
||||
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (!note || note.userId !== userId) throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync } from 'fs';
|
||||
import { writeFile, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { db } from './db/index.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { attachments } from './db/schema.js';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
@@ -16,6 +16,7 @@ if (!existsSync(ATTACHMENTS_DIR)) {
|
||||
}
|
||||
|
||||
export async function saveAttachment(
|
||||
db: Db,
|
||||
noteId: string,
|
||||
file: File,
|
||||
userId: number,
|
||||
@@ -59,7 +60,7 @@ export async function saveAttachment(
|
||||
* Batch fetch attachments for multiple notes in a single query.
|
||||
* Returns a Map of noteId → Attachment[].
|
||||
*/
|
||||
export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachment[]> {
|
||||
export function fetchAttachmentsForNotes(db: Db, noteIds: string[]): Map<string, Attachment[]> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
@@ -80,7 +81,7 @@ export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachm
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function getAttachment(id: string, noteId: string) {
|
||||
export async function getAttachment(db: Db, id: string, noteId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(attachments)
|
||||
@@ -88,11 +89,11 @@ export async function getAttachment(id: string, noteId: string) {
|
||||
.get();
|
||||
}
|
||||
|
||||
export async function getAttachmentsByNote(noteId: string) {
|
||||
export async function getAttachmentsByNote(db: Db, noteId: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
|
||||
}
|
||||
|
||||
export async function updateAttachment(id: string, noteId: string, data: { featured: boolean }) {
|
||||
export async function updateAttachment(db: Db, id: string, noteId: string, data: { featured: boolean }) {
|
||||
const [updated] = await db
|
||||
.update(attachments)
|
||||
.set({ featured: data.featured })
|
||||
@@ -101,8 +102,8 @@ export async function updateAttachment(id: string, noteId: string, data: { featu
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string, noteId: string) {
|
||||
const attachment = await getAttachment(id, noteId);
|
||||
export async function deleteAttachment(db: Db, id: string, noteId: string) {
|
||||
const attachment = await getAttachment(db, id, noteId);
|
||||
if (attachment) {
|
||||
try {
|
||||
await unlink(attachment.path);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import { fetchCollaboratorsForNotes, addCollaborator, removeCollaborator } from './collaborators.js';
|
||||
|
||||
let db: Db;
|
||||
const OWNER_ID = 1;
|
||||
const COLLAB_ID = 2;
|
||||
|
||||
function seedUsers() {
|
||||
db.insert(users)
|
||||
.values([
|
||||
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string) {
|
||||
db.insert(notes)
|
||||
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUsers();
|
||||
});
|
||||
|
||||
describe('fetchCollaboratorsForNotes', () => {
|
||||
it('should return Map with user details for shared notes', () => {
|
||||
seedNote('n1');
|
||||
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
|
||||
|
||||
const map = fetchCollaboratorsForNotes(db, ['n1']);
|
||||
const collabs = map.get('n1');
|
||||
expect(collabs).toHaveLength(1);
|
||||
expect(collabs![0].userId).toBe(COLLAB_ID);
|
||||
expect(collabs![0].displayName).toBe('Collab');
|
||||
expect(collabs![0].email).toBe('collab@test.com');
|
||||
});
|
||||
|
||||
it('should return empty Map for notes with no collaborators', () => {
|
||||
seedNote('n1');
|
||||
const map = fetchCollaboratorsForNotes(db, ['n1']);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addCollaborator', () => {
|
||||
it('should insert collaborator record', () => {
|
||||
seedNote('n1');
|
||||
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
|
||||
|
||||
const rows = db.select().from(noteCollaborators).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].userId).toBe(COLLAB_ID);
|
||||
expect(rows[0].addedBy).toBe(OWNER_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCollaborator', () => {
|
||||
it('should clean up noteUserState on removal', () => {
|
||||
seedNote('n1');
|
||||
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'n1', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 0 })
|
||||
.run();
|
||||
|
||||
removeCollaborator(db, 'n1', COLLAB_ID);
|
||||
|
||||
const collabs = db.select().from(noteCollaborators).all();
|
||||
expect(collabs).toHaveLength(0);
|
||||
|
||||
const states = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, COLLAB_ID)))
|
||||
.all();
|
||||
expect(states).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db } from './db/index.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteCollaborators, noteUserState, users } from './db/schema.js';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
@@ -7,7 +7,7 @@ import type { Collaborator } from '$lib/types/index.js';
|
||||
* Batch fetch collaborators for multiple notes.
|
||||
* Returns a Map of noteId → Collaborator[].
|
||||
*/
|
||||
export function fetchCollaboratorsForNotes(noteIds: string[]): Map<string, Collaborator[]> {
|
||||
export function fetchCollaboratorsForNotes(db: Db, noteIds: string[]): Map<string, Collaborator[]> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
@@ -44,7 +44,7 @@ export function fetchCollaboratorsForNotes(noteIds: string[]): Map<string, Colla
|
||||
/**
|
||||
* Add a collaborator to a note.
|
||||
*/
|
||||
export function addCollaborator(noteId: string, userId: number, addedBy: number): void {
|
||||
export function addCollaborator(db: Db, noteId: string, userId: number, addedBy: number): void {
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId, userId, addedBy, addedAt: new Date() })
|
||||
.run();
|
||||
@@ -53,7 +53,7 @@ export function addCollaborator(noteId: string, userId: number, addedBy: number)
|
||||
/**
|
||||
* Remove a collaborator from a note. Also cleans up their per-user state.
|
||||
*/
|
||||
export function removeCollaborator(noteId: string, userId: number): void {
|
||||
export function removeCollaborator(db: Db, noteId: string, userId: number): void {
|
||||
db.delete(noteCollaborators)
|
||||
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
|
||||
.run();
|
||||
@@ -65,7 +65,7 @@ export function removeCollaborator(noteId: string, userId: number): void {
|
||||
/**
|
||||
* Get all collaborator user IDs for a note.
|
||||
*/
|
||||
export function getCollaboratorIds(noteId: string): number[] {
|
||||
export function getCollaboratorIds(db: Db, noteId: string): number[] {
|
||||
return db
|
||||
.select({ userId: noteCollaborators.userId })
|
||||
.from(noteCollaborators)
|
||||
|
||||
@@ -115,6 +115,26 @@ sqlite.exec(`
|
||||
success INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
checklist_mode INTEGER NOT NULL DEFAULT 0,
|
||||
color TEXT NOT NULL DEFAULT 'default',
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(note_id, version)
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: detect old schema (no email column on users) and add columns
|
||||
@@ -186,6 +206,9 @@ sqlite.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS note_collaborators_unique ON note_collaborators(note_id, user_id);
|
||||
CREATE INDEX IF NOT EXISTS note_collaborators_user_id_idx ON note_collaborators(user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS note_user_state_unique ON note_user_state(note_id, user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_preferences_user_key_unique ON user_preferences(user_id, key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS note_versions_note_id_idx ON note_versions(note_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -247,4 +270,5 @@ if (pwCol && pwCol.notnull) {
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export type Db = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<typeof schema>;
|
||||
export { sqlite };
|
||||
|
||||
@@ -175,3 +175,37 @@ export const loginAttempts = sqliteTable(
|
||||
},
|
||||
(table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)]
|
||||
);
|
||||
|
||||
export const userPreferences = sqliteTable(
|
||||
'user_preferences',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
key: text('key').notNull(),
|
||||
value: text('value').notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
|
||||
},
|
||||
(table) => [uniqueIndex('user_preferences_user_key_unique').on(table.userId, table.key)]
|
||||
);
|
||||
|
||||
export const noteVersions = sqliteTable(
|
||||
'note_versions',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
version: integer('version').notNull(),
|
||||
title: text('title').notNull().default(''),
|
||||
content: text('content').notNull().default(''),
|
||||
checklistMode: integer('checklist_mode', { mode: 'boolean' }).notNull().default(false),
|
||||
color: text('color').notNull().default('default'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('note_versions_note_id_idx').on(table.noteId),
|
||||
uniqueIndex('note_versions_note_id_version_unique').on(table.noteId, table.version)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -94,6 +94,8 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
thumbnail_path TEXT,
|
||||
featured INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -125,6 +127,28 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE user_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key);
|
||||
|
||||
CREATE TABLE note_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
checklist_mode INTEGER NOT NULL DEFAULT 0,
|
||||
color TEXT NOT NULL DEFAULT 'default',
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(note_id, version)
|
||||
);
|
||||
CREATE INDEX note_versions_note_id_idx ON note_versions(note_id);
|
||||
`);
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
|
||||
let transporter: Transporter | null = null;
|
||||
|
||||
/**
|
||||
* Check if SMTP email is configured via environment variables.
|
||||
*/
|
||||
export function isEmailConfigured(): boolean {
|
||||
return !!process.env.SMTP_HOST;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the nodemailer transport (lazy singleton).
|
||||
*/
|
||||
function getTransporter(): Transporter {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_PORT === '465',
|
||||
auth:
|
||||
process.env.SMTP_USER
|
||||
? {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS || ''
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email. Best-effort: logs errors but does not throw.
|
||||
*/
|
||||
async function sendEmail(to: string, subject: string, html: string): Promise<void> {
|
||||
try {
|
||||
const transport = getTransporter();
|
||||
await transport.sendMail({
|
||||
from: process.env.SMTP_FROM || 'Crumbs <noreply@localhost>',
|
||||
to,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[email] Failed to send:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a share notification email to a collaborator.
|
||||
*/
|
||||
export async function sendShareNotification(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
fromName: string,
|
||||
noteTitle: string,
|
||||
appUrl: string
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = `${fromName} shared a note with you`;
|
||||
const html = buildShareEmailHtml(toName, fromName, noteTitle, appUrl);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a welcome email to a newly created user.
|
||||
*/
|
||||
export async function sendWelcomeEmail(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
appUrl: string
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = 'Welcome to Crumbs';
|
||||
const html = buildWelcomeEmailHtml(toName, appUrl);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a password reset notification to a user (triggered by admin reset).
|
||||
*/
|
||||
export async function sendPasswordResetEmail(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
appUrl: string
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = 'Your Crumbs password has been reset';
|
||||
const html = buildPasswordResetEmailHtml(toName, appUrl);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an account deletion confirmation email.
|
||||
*/
|
||||
export async function sendAccountDeletedEmail(toEmail: string, toName: string): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = 'Your Crumbs account has been deleted';
|
||||
const html = buildAccountDeletedEmailHtml(toName);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to the old email when the account email address changes.
|
||||
*/
|
||||
export async function sendEmailChangedEmail(
|
||||
oldEmail: string,
|
||||
toName: string,
|
||||
newEmail: string
|
||||
): Promise<void> {
|
||||
if (!oldEmail) return;
|
||||
|
||||
const subject = 'Your Crumbs email address has been changed';
|
||||
const html = buildEmailChangedEmailHtml(toName, newEmail);
|
||||
await sendEmail(oldEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a role change notification to a user.
|
||||
*/
|
||||
export async function sendRoleChangedEmail(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
newRole: 'admin' | 'user',
|
||||
appUrl: string
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = 'Your Crumbs account role has been updated';
|
||||
const html = buildRoleChangedEmailHtml(toName, newRole, appUrl);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to a collaborator that they have been removed from a note.
|
||||
*/
|
||||
export async function sendCollaboratorRemovedEmail(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
ownerName: string,
|
||||
noteTitle: string,
|
||||
appUrl: string
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = `You've been removed from a shared note`;
|
||||
const html = buildCollaboratorRemovedEmailHtml(toName, ownerName, noteTitle, appUrl);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to collaborators that a shared note has been permanently deleted.
|
||||
*/
|
||||
export async function sendNotePermanentlyDeletedEmail(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
noteTitle: string
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = `A shared note has been deleted`;
|
||||
const html = buildNotePermanentlyDeletedEmailHtml(toName, noteTitle);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a security alert to a user when their account is locked due to too many failed logins.
|
||||
*/
|
||||
export async function sendAccountLockedEmail(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
retryAfterMinutes: number
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = 'Crumbs account temporarily locked';
|
||||
const html = buildAccountLockedEmailHtml(toName, retryAfterMinutes);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
function buildShareEmailHtml(
|
||||
toName: string,
|
||||
fromName: string,
|
||||
noteTitle: string,
|
||||
appUrl: string
|
||||
): string {
|
||||
const displayName = toName || 'there';
|
||||
const escapedTitle = escapeHtml(noteTitle);
|
||||
const escapedFrom = escapeHtml(fromName || 'Someone');
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Note shared with you</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${escapeHtml(displayName)},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
<strong>${escapedFrom}</strong> shared a note with you: <strong>“${escapedTitle}”</strong>
|
||||
</p>
|
||||
${buildOpenButton(appUrl)}`;
|
||||
return buildEmailLayout(content, 'You’re receiving this because someone shared a note with you on Crumbs.');
|
||||
}
|
||||
|
||||
function buildEmailLayout(content: string, footer: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:0;background-color:#f0e6d3;font-family:'JetBrains Mono',monospace;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0e6d3;padding:40px 20px;">
|
||||
<tr><td align="center">
|
||||
<table width="500" cellpadding="0" cellspacing="0" style="background-color:#faf5eb;border:1px solid #d4cabb;box-shadow:2px 2px 0px #d4cabb;">
|
||||
<tr><td style="padding:32px;">
|
||||
<p style="margin:0 0 8px;font-size:11px;color:#C8860A;text-transform:uppercase;letter-spacing:2px;">Crumbs</p>
|
||||
${content}
|
||||
<p style="margin:0;font-size:11px;color:#6b6272;line-height:1.5;">${footer}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function buildOpenButton(appUrl: string, label = 'Open Crumbs'): string {
|
||||
return `<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||
<tr><td style="background-color:#C8860A;box-shadow:2px 2px 0px #1a1a2e;">
|
||||
<a href="${escapeHtml(appUrl)}" style="display:inline-block;padding:10px 24px;color:#faf5eb;font-size:13px;font-weight:bold;text-decoration:none;font-family:'JetBrains Mono',monospace;">${escapeHtml(label)}</a>
|
||||
</td></tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function buildWelcomeEmailHtml(toName: string, appUrl: string): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Welcome to Crumbs</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
An admin has created an account for you on Crumbs. You can log in and start taking notes right away.
|
||||
</p>
|
||||
${buildOpenButton(appUrl)}`;
|
||||
return buildEmailLayout(content, 'You’re receiving this because an admin created an account for you on Crumbs.');
|
||||
}
|
||||
|
||||
function buildPasswordResetEmailHtml(toName: string, appUrl: string): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Password reset</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
An admin has reset your Crumbs password. Please log in and change it to something only you know.
|
||||
</p>
|
||||
${buildOpenButton(appUrl)}`;
|
||||
return buildEmailLayout(content, 'You’re receiving this because an admin reset your password on Crumbs. If you didn’t expect this, please contact your admin.');
|
||||
}
|
||||
|
||||
function buildAccountDeletedEmailHtml(toName: string): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Account deleted</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Your Crumbs account and all associated data have been permanently deleted.
|
||||
</p>`;
|
||||
return buildEmailLayout(content, 'You’re receiving this as confirmation that your Crumbs account was deleted.');
|
||||
}
|
||||
|
||||
function buildEmailChangedEmailHtml(toName: string, newEmail: string): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Email address changed</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Your Crumbs account email address has been changed to <strong>${escapeHtml(newEmail)}</strong>. If you did not make this change, please contact your admin immediately.
|
||||
</p>`;
|
||||
return buildEmailLayout(content, 'You’re receiving this security notice because your Crumbs account email was updated.');
|
||||
}
|
||||
|
||||
function buildRoleChangedEmailHtml(toName: string, newRole: 'admin' | 'user', appUrl: string): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const roleLabel = newRole === 'admin' ? 'Admin' : 'User';
|
||||
const detail = newRole === 'admin'
|
||||
? 'You now have admin privileges, including the ability to manage users and settings.'
|
||||
: 'Your account no longer has admin privileges.';
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Role updated: ${roleLabel}</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Your Crumbs account role has been updated to <strong>${escapeHtml(roleLabel)}</strong>. ${escapeHtml(detail)}
|
||||
</p>
|
||||
${buildOpenButton(appUrl)}`;
|
||||
return buildEmailLayout(content, 'You’re receiving this because your account role was changed on Crumbs.');
|
||||
}
|
||||
|
||||
function buildCollaboratorRemovedEmailHtml(
|
||||
toName: string,
|
||||
ownerName: string,
|
||||
noteTitle: string,
|
||||
appUrl: string
|
||||
): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const escapedOwner = escapeHtml(ownerName || 'Someone');
|
||||
const escapedTitle = escapeHtml(noteTitle);
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Removed from shared note</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
<strong>${escapedOwner}</strong> has removed you from the shared note <strong>“${escapedTitle}”</strong>. You no longer have access to this note.
|
||||
</p>
|
||||
${buildOpenButton(appUrl)}`;
|
||||
return buildEmailLayout(content, 'You’re receiving this because you were removed from a shared note on Crumbs.');
|
||||
}
|
||||
|
||||
function buildNotePermanentlyDeletedEmailHtml(toName: string, noteTitle: string): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const escapedTitle = escapeHtml(noteTitle);
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Shared note deleted</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
The shared note <strong>“${escapedTitle}”</strong> has been permanently deleted by its owner. You no longer have access to it.
|
||||
</p>`;
|
||||
return buildEmailLayout(content, 'You’re receiving this because a shared note you had access to was deleted on Crumbs.');
|
||||
}
|
||||
|
||||
function buildAccountLockedEmailHtml(toName: string, retryAfterMinutes: number): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Account temporarily locked</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Too many failed login attempts were made on your Crumbs account. For your security, login has been temporarily blocked for <strong>${retryAfterMinutes} minute${retryAfterMinutes === 1 ? '' : 's'}</strong>. If this wasn’t you, please change your password once you regain access.
|
||||
</p>`;
|
||||
return buildEmailLayout(content, 'You’re receiving this security alert because of repeated failed login attempts on your Crumbs account.');
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
reorderNotes
|
||||
} from '../notes-service.js';
|
||||
import { saveAttachment } from '../attachments.js';
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
const NOTE_COLORS = [
|
||||
'default',
|
||||
@@ -45,7 +46,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
tag: z.string().optional().describe('Filter by tag name')
|
||||
},
|
||||
async ({ filter = 'all', tag }: { filter?: string; tag?: string }) => {
|
||||
let result = listNotes(userId, filter as NoteFilter);
|
||||
let result = listNotes(db, userId, filter as NoteFilter);
|
||||
|
||||
if (tag) {
|
||||
result = result.filter((n) =>
|
||||
@@ -64,7 +65,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Get a single note by ID with its tags and attachments',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const note = getNote(userId, id);
|
||||
const note = getNote(db, userId, id);
|
||||
if (!note) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -88,7 +89,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
checklistMode: z.boolean().optional().describe('Enable checklist mode')
|
||||
},
|
||||
async ({ title, content, color, checklistMode }: { title?: string; content?: string; color?: string; checklistMode?: boolean }) => {
|
||||
const note = createNote(userId, { title, content, color, checklistMode });
|
||||
const note = createNote(db, userId, { title, content, color, checklistMode });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
|
||||
};
|
||||
@@ -107,7 +108,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
checklistMode: z.boolean().optional().describe('Enable/disable checklist mode')
|
||||
},
|
||||
async ({ id, ...updates }: { id: string; title?: string; content?: string; color?: string; pinned?: boolean; checklistMode?: boolean }) => {
|
||||
const result = updateNote(userId, id, updates);
|
||||
const result = updateNote(db, userId, id, updates);
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -126,7 +127,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Move a note to trash',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { trashed: true });
|
||||
const result = updateNote(db, userId, id, { trashed: true });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -144,7 +145,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Restore a note from trash',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { trashed: false });
|
||||
const result = updateNote(db, userId, id, { trashed: false });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -162,7 +163,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Archive a note',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { archived: true });
|
||||
const result = updateNote(db, userId, id, { archived: true });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -180,7 +181,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Unarchive a note',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { archived: false });
|
||||
const result = updateNote(db, userId, id, { archived: false });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -198,7 +199,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Permanently delete a note',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const existing = getNote(userId, id);
|
||||
const existing = getNote(db, userId, id);
|
||||
if (!existing) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -206,7 +207,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
};
|
||||
}
|
||||
|
||||
deleteNote(userId, id);
|
||||
deleteNote(db, userId, id);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -223,7 +224,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
'Search notes by title, content, or tag name',
|
||||
{ query: z.string().describe('Search query') },
|
||||
async ({ query }: { query: string }) => {
|
||||
const results = searchNotes(userId, query);
|
||||
const results = searchNotes(db, userId, query);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }]
|
||||
};
|
||||
@@ -231,7 +232,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
);
|
||||
|
||||
server.tool('list_tags', 'List all tags', {}, async () => {
|
||||
const allTags = listAllTags(userId);
|
||||
const allTags = listAllTags(db, userId);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(allTags, null, 2) }]
|
||||
};
|
||||
@@ -245,7 +246,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
|
||||
},
|
||||
async ({ id, pinned }: { id: string; pinned: boolean }) => {
|
||||
const result = updateNote(userId, id, { pinned });
|
||||
const result = updateNote(db, userId, id, { pinned });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -277,7 +278,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
.describe('Array of note IDs with their new sort orders')
|
||||
},
|
||||
async ({ orders }: { orders: { id: string; sortOrder: number }[] }) => {
|
||||
reorderNotes(userId, orders);
|
||||
reorderNotes(db, userId, orders);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Reordered ${orders.length} notes` }]
|
||||
};
|
||||
@@ -292,7 +293,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
imageUrl: z.string().url().describe('URL of the image to fetch')
|
||||
},
|
||||
async ({ noteId, imageUrl }: { noteId: string; imageUrl: string }) => {
|
||||
const existing = getNote(userId, noteId);
|
||||
const existing = getNote(db, userId, noteId);
|
||||
if (!existing) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
@@ -320,7 +321,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
const filename = `upload_${Date.now()}.${ext}`;
|
||||
|
||||
const file = new File([buffer], filename, { type: contentType });
|
||||
const attachment = await saveAttachment(noteId, file, userId);
|
||||
const attachment = await saveAttachment(db, noteId, file, userId);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(attachment, null, 2) }]
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, noteVersions, users } from './db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import {
|
||||
listNotes,
|
||||
getNote,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
searchNotes,
|
||||
reorderNotes
|
||||
} from './notes-service.js';
|
||||
|
||||
let db: Db;
|
||||
|
||||
const OWNER_ID = 1;
|
||||
const COLLAB_ID = 2;
|
||||
|
||||
function seedUsers() {
|
||||
db.insert(users)
|
||||
.values([
|
||||
{
|
||||
email: 'owner@test.com',
|
||||
displayName: 'Owner',
|
||||
role: 'admin',
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
email: 'collab@test.com',
|
||||
displayName: 'Collab',
|
||||
role: 'user',
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
}
|
||||
])
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(
|
||||
id: string,
|
||||
overrides: Partial<typeof notes.$inferInsert> = {}
|
||||
) {
|
||||
const now = new Date();
|
||||
db.insert(notes)
|
||||
.values({
|
||||
id,
|
||||
userId: OWNER_ID,
|
||||
title: '',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
function shareNote(noteId: string, userId = COLLAB_ID) {
|
||||
db.insert(noteCollaborators)
|
||||
.values({
|
||||
noteId,
|
||||
userId,
|
||||
addedBy: OWNER_ID,
|
||||
addedAt: new Date()
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUsers();
|
||||
});
|
||||
|
||||
describe('listNotes', () => {
|
||||
it('should return only active owned notes (excludes archived/trashed)', () => {
|
||||
seedNote('active', { title: 'Active' });
|
||||
seedNote('archived', { title: 'Archived', archived: true });
|
||||
seedNote('trashed', { title: 'Trashed', trashed: true });
|
||||
|
||||
const result = listNotes(db, OWNER_ID);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Active');
|
||||
});
|
||||
|
||||
it('should include shared notes with per-user state overlay', () => {
|
||||
seedNote('shared', { title: 'Shared Note' });
|
||||
shareNote('shared');
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 5 })
|
||||
.run();
|
||||
|
||||
const result = listNotes(db, COLLAB_ID);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Shared Note');
|
||||
expect(result[0].pinned).toBeTruthy();
|
||||
expect(result[0].sortOrder).toBe(5);
|
||||
});
|
||||
|
||||
it('should sort pinned first, then by updatedAt desc', () => {
|
||||
const old = new Date('2024-01-01');
|
||||
const recent = new Date('2024-01-02');
|
||||
seedNote('old-pinned', { title: 'Old Pinned', pinned: true, updatedAt: old, createdAt: old });
|
||||
seedNote('recent', { title: 'Recent', updatedAt: recent, createdAt: recent });
|
||||
seedNote('old', { title: 'Old', updatedAt: old, createdAt: old });
|
||||
|
||||
const result = listNotes(db, OWNER_ID);
|
||||
expect(result.map((n) => n.title)).toEqual(['Old Pinned', 'Recent', 'Old']);
|
||||
});
|
||||
|
||||
it('should return only archived notes with archived filter', () => {
|
||||
seedNote('active', { title: 'Active' });
|
||||
seedNote('archived', { title: 'Archived', archived: true });
|
||||
seedNote('shared', { title: 'Shared Archived' });
|
||||
shareNote('shared');
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 0 })
|
||||
.run();
|
||||
|
||||
// Owner sees their archived note
|
||||
const ownerResult = listNotes(db, OWNER_ID, 'archived');
|
||||
expect(ownerResult).toHaveLength(1);
|
||||
expect(ownerResult[0].title).toBe('Archived');
|
||||
|
||||
// Collaborator sees shared note they archived
|
||||
const collabResult = listNotes(db, COLLAB_ID, 'archived');
|
||||
expect(collabResult).toHaveLength(1);
|
||||
expect(collabResult[0].title).toBe('Shared Archived');
|
||||
});
|
||||
|
||||
it('should exclude shared notes from trashed filter', () => {
|
||||
seedNote('trashed', { title: 'Trashed', trashed: true });
|
||||
seedNote('shared', { title: 'Shared' });
|
||||
shareNote('shared');
|
||||
|
||||
const result = listNotes(db, COLLAB_ID, 'trashed');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNote', () => {
|
||||
it('should return null for non-existent note', () => {
|
||||
expect(getNote(db, OWNER_ID, 'nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if user has no access', () => {
|
||||
seedNote('private', { title: 'Private' });
|
||||
expect(getNote(db, COLLAB_ID, 'private')).toBeNull();
|
||||
});
|
||||
|
||||
it('should overlay collaborator per-user state', () => {
|
||||
seedNote('shared', { title: 'Shared', pinned: true, sortOrder: 10 });
|
||||
shareNote('shared');
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 5 })
|
||||
.run();
|
||||
|
||||
const note = getNote(db, COLLAB_ID, 'shared');
|
||||
expect(note).not.toBeNull();
|
||||
expect(note!.pinned).toBeFalsy();
|
||||
expect(note!.archived).toBeTruthy();
|
||||
expect(note!.sortOrder).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNote', () => {
|
||||
it('should generate UUID and set version=1', () => {
|
||||
const note = createNote(db, OWNER_ID, { title: 'Test' });
|
||||
expect(note.id).toBeDefined();
|
||||
expect(note.id.length).toBe(36); // UUID format
|
||||
expect(note.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should use provided ID when given', () => {
|
||||
const note = createNote(db, OWNER_ID, { id: 'custom-id', title: 'Test' });
|
||||
expect(note.id).toBe('custom-id');
|
||||
});
|
||||
|
||||
it('should extract tags from content', () => {
|
||||
const note = createNote(db, OWNER_ID, {
|
||||
title: 'My #todo note',
|
||||
content: 'Some #important content'
|
||||
});
|
||||
expect(note.tags).toContain('todo');
|
||||
expect(note.tags).toContain('important');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateNote', () => {
|
||||
it('should allow owner to update shared fields directly', () => {
|
||||
seedNote('n1', { title: 'Original' });
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Updated' });
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.title).toBe('Updated');
|
||||
});
|
||||
|
||||
it('should allow collaborator to edit content', () => {
|
||||
seedNote('n1', { title: 'Original', content: 'Old' });
|
||||
shareNote('n1');
|
||||
const updated = updateNote(db, COLLAB_ID, 'n1', { content: 'New content' });
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.content).toBe('New content');
|
||||
});
|
||||
|
||||
it('should route collaborator per-user fields to noteUserState', () => {
|
||||
seedNote('n1', { title: 'Note', pinned: false });
|
||||
shareNote('n1');
|
||||
updateNote(db, COLLAB_ID, 'n1', { pinned: true, sortOrder: 7 });
|
||||
|
||||
// Owner's note pinned should be unchanged
|
||||
const ownerView = getNote(db, OWNER_ID, 'n1');
|
||||
expect(ownerView!.pinned).toBeFalsy();
|
||||
|
||||
// Collaborator sees their per-user state
|
||||
const collabView = getNote(db, COLLAB_ID, 'n1');
|
||||
expect(collabView!.pinned).toBeTruthy();
|
||||
expect(collabView!.sortOrder).toBe(7);
|
||||
});
|
||||
|
||||
it('should return null when collaborator tries to trash', () => {
|
||||
seedNote('n1', { title: 'Note' });
|
||||
shareNote('n1');
|
||||
const result = updateNote(db, COLLAB_ID, 'n1', { trashed: true });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should increment version on update', () => {
|
||||
seedNote('n1', { title: 'V1' });
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'V2' });
|
||||
expect(updated!.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should create a snapshot when content changes', () => {
|
||||
seedNote('n1', { title: 'Original', content: 'Hello', color: 'default', checklistMode: false });
|
||||
updateNote(db, OWNER_ID, 'n1', { title: 'Changed' });
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not create a snapshot when no content field actually changed', () => {
|
||||
seedNote('n1', { title: 'Same', content: 'Same', color: 'default', checklistMode: false });
|
||||
// Send the same values — nothing actually changes
|
||||
updateNote(db, OWNER_ID, 'n1', { title: 'Same', content: 'Same' });
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not create a snapshot when only non-content fields change (pinned, archived)', () => {
|
||||
seedNote('n1', { title: 'Note', pinned: false });
|
||||
updateNote(db, OWNER_ID, 'n1', { pinned: true });
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should extract and sync tags on title/content change', () => {
|
||||
seedNote('n1', { title: 'Original' });
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
|
||||
expect(updated!.tags).toContain('newtag');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteNote', () => {
|
||||
it('should allow owner to permanently delete', () => {
|
||||
seedNote('n1', { title: 'Delete me' });
|
||||
const result = deleteNote(db, OWNER_ID, 'n1');
|
||||
expect(result).toBe(true);
|
||||
expect(getNote(db, OWNER_ID, 'n1')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return false for non-existent or unauthorized', () => {
|
||||
seedNote('n1', { title: 'Private' });
|
||||
expect(deleteNote(db, COLLAB_ID, 'n1')).toBe(false);
|
||||
expect(deleteNote(db, OWNER_ID, 'nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchNotes', () => {
|
||||
it('should match title and content', () => {
|
||||
seedNote('n1', { title: 'Meeting notes', content: 'Discuss budget' });
|
||||
seedNote('n2', { title: 'Shopping list', content: 'Buy milk' });
|
||||
|
||||
const results = searchNotes(db, OWNER_ID, 'budget');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('n1');
|
||||
});
|
||||
|
||||
it('should include shared notes in results', () => {
|
||||
seedNote('shared', { title: 'Shared doc', content: 'Collaboration' });
|
||||
shareNote('shared');
|
||||
|
||||
const results = searchNotes(db, COLLAB_ID, 'Collaboration');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('shared');
|
||||
});
|
||||
|
||||
it('should return empty array for empty query', () => {
|
||||
seedNote('n1', { title: 'Test' });
|
||||
expect(searchNotes(db, OWNER_ID, '')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderNotes', () => {
|
||||
it('should update owner sortOrder in notes table', () => {
|
||||
seedNote('n1', { title: 'A' });
|
||||
seedNote('n2', { title: 'B' });
|
||||
reorderNotes(db, OWNER_ID, [
|
||||
{ id: 'n1', sortOrder: 2 },
|
||||
{ id: 'n2', sortOrder: 1 }
|
||||
]);
|
||||
|
||||
const n1 = getNote(db, OWNER_ID, 'n1');
|
||||
const n2 = getNote(db, OWNER_ID, 'n2');
|
||||
expect(n1!.sortOrder).toBe(2);
|
||||
expect(n2!.sortOrder).toBe(1);
|
||||
});
|
||||
|
||||
it('should update collaborator sortOrder in noteUserState', () => {
|
||||
seedNote('n1', { title: 'Note', sortOrder: 0 });
|
||||
shareNote('n1');
|
||||
reorderNotes(db, COLLAB_ID, [{ id: 'n1', sortOrder: 5 }]);
|
||||
|
||||
// Owner's sortOrder unchanged
|
||||
const ownerView = getNote(db, OWNER_ID, 'n1');
|
||||
expect(ownerView!.sortOrder).toBe(0);
|
||||
|
||||
// Collaborator sees their sortOrder
|
||||
const collabView = getNote(db, COLLAB_ID, 'n1');
|
||||
expect(collabView!.sortOrder).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db as defaultDb } from './db/index.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray, sql } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -7,10 +7,9 @@ import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
import { fetchAttachmentsForNotes } from './attachments.js';
|
||||
import { fetchCollaboratorsForNotes } from './collaborators.js';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
import { createSnapshot } from './versions-service.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
const db = defaultDb;
|
||||
|
||||
interface NoteRow {
|
||||
id: string;
|
||||
userId: number;
|
||||
@@ -28,11 +27,11 @@ interface NoteRow {
|
||||
version: number;
|
||||
}
|
||||
|
||||
function hydrateNotes(noteRows: NoteRow[], userId: number) {
|
||||
function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
|
||||
const noteIds = noteRows.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
const collabMap = fetchCollaboratorsForNotes(noteIds);
|
||||
const tagMap = fetchTagsForNotes(db, noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(db, noteIds);
|
||||
const collabMap = fetchCollaboratorsForNotes(db, noteIds);
|
||||
|
||||
return noteRows.map((note) => {
|
||||
const collaborators = collabMap.get(note.id) ?? [];
|
||||
@@ -52,7 +51,7 @@ function hydrateNotes(noteRows: NoteRow[], userId: number) {
|
||||
/**
|
||||
* Get shared notes for a user with per-user state overlay.
|
||||
*/
|
||||
function getSharedNotes(userId: number, filter: 'all' | 'archived'): NoteRow[] {
|
||||
function getSharedNotes(db: Db, userId: number, filter: 'all' | 'archived'): NoteRow[] {
|
||||
const rows = db
|
||||
.select({
|
||||
id: notes.id,
|
||||
@@ -90,7 +89,7 @@ function getSharedNotes(userId: number, filter: 'all' | 'archived'): NoteRow[] {
|
||||
return rows as NoteRow[];
|
||||
}
|
||||
|
||||
export function listNotes(userId: number, filter: NoteFilter = 'all') {
|
||||
export function listNotes(db: Db, userId: number, filter: NoteFilter = 'all') {
|
||||
// Owned notes
|
||||
let conditions;
|
||||
switch (filter) {
|
||||
@@ -114,7 +113,7 @@ export function listNotes(userId: number, filter: NoteFilter = 'all') {
|
||||
// Shared notes (not for trashed filter — collaborators don't see trash)
|
||||
let sharedNotes: NoteRow[] = [];
|
||||
if (filter !== 'trashed') {
|
||||
sharedNotes = getSharedNotes(userId, filter === 'archived' ? 'archived' : 'all');
|
||||
sharedNotes = getSharedNotes(db, userId, filter === 'archived' ? 'archived' : 'all');
|
||||
}
|
||||
|
||||
const combined = [...ownedNotes, ...sharedNotes];
|
||||
@@ -124,11 +123,11 @@ export function listNotes(userId: number, filter: NoteFilter = 'all') {
|
||||
return b.updatedAt.getTime() - a.updatedAt.getTime();
|
||||
});
|
||||
|
||||
return hydrateNotes(combined, userId);
|
||||
return hydrateNotes(db, combined, userId);
|
||||
}
|
||||
|
||||
export function getNote(userId: number, id: string) {
|
||||
const { canAccess, isOwner } = canAccessNote(id, userId);
|
||||
export function getNote(db: Db, userId: number, id: string) {
|
||||
const { canAccess, isOwner } = canAccessNote(db, id, userId);
|
||||
if (!canAccess) return null;
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
@@ -151,7 +150,7 @@ export function getNote(userId: number, id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
const result = hydrateNotes([effectiveNote], userId);
|
||||
const result = hydrateNotes(db, [effectiveNote], userId);
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -165,7 +164,7 @@ export interface CreateNoteInput {
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export function createNote(userId: number, input: CreateNoteInput) {
|
||||
export function createNote(db: Db, userId: number, input: CreateNoteInput) {
|
||||
const now = new Date();
|
||||
const id = input.id || uuidv4();
|
||||
|
||||
@@ -192,7 +191,7 @@ export function createNote(userId: number, input: CreateNoteInput) {
|
||||
db.transaction((tx) => {
|
||||
tx.insert(notes).values(newNote).run();
|
||||
});
|
||||
syncNoteTags(id, extractedTags, userId);
|
||||
syncNoteTags(db, id, extractedTags, userId);
|
||||
|
||||
return { ...newNote, tags: extractedTags };
|
||||
}
|
||||
@@ -211,8 +210,8 @@ export interface UpdateNoteInput {
|
||||
/** Per-user fields that go to noteUserState for collaborators */
|
||||
const PER_USER_FIELDS = ['pinned', 'archived', 'sortOrder'] as const;
|
||||
|
||||
export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
|
||||
const { canAccess, isOwner } = canAccessNote(id, userId);
|
||||
export function updateNote(db: Db, userId: number, id: string, input: UpdateNoteInput) {
|
||||
const { canAccess, isOwner } = canAccessNote(db, id, userId);
|
||||
if (!canAccess) return null;
|
||||
|
||||
const existing = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
@@ -291,14 +290,34 @@ export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
|
||||
if (updated) {
|
||||
const content = `${updated.title} ${updated.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
syncNoteTags(id, extractedTags, existing.userId);
|
||||
syncNoteTags(db, id, extractedTags, existing.userId);
|
||||
}
|
||||
}
|
||||
|
||||
return getNote(userId, id);
|
||||
// Create a version snapshot only when at least one content field actually changed
|
||||
const hasActualContentChange =
|
||||
(input.title !== undefined && input.title !== existing.title) ||
|
||||
(input.content !== undefined && input.content !== existing.content) ||
|
||||
(input.color !== undefined && input.color !== existing.color) ||
|
||||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
|
||||
|
||||
if (hasActualContentChange) {
|
||||
const saved = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
if (saved) {
|
||||
createSnapshot(db, id, {
|
||||
version: saved.version,
|
||||
title: saved.title,
|
||||
content: saved.content,
|
||||
checklistMode: saved.checklistMode,
|
||||
color: saved.color
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return getNote(db, userId, id);
|
||||
}
|
||||
|
||||
export function deleteNote(userId: number, id: string): boolean {
|
||||
export function deleteNote(db: Db, userId: number, id: string): boolean {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(notes)
|
||||
@@ -312,7 +331,7 @@ export function deleteNote(userId: number, id: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function searchNotes(userId: number, query: string) {
|
||||
export function searchNotes(db: Db, userId: number, query: string) {
|
||||
if (!query) return [];
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
@@ -386,17 +405,17 @@ export function searchNotes(userId: number, query: string) {
|
||||
}
|
||||
|
||||
const combined = [...ownedResults, ...sharedResults, ...extraNotes] as NoteRow[];
|
||||
return hydrateNotes(combined, userId);
|
||||
return hydrateNotes(db, combined, userId);
|
||||
}
|
||||
|
||||
export function listAllTags(userId: number) {
|
||||
export function listAllTags(db: Db, userId: number) {
|
||||
return db.select().from(tags).where(eq(tags.userId, userId)).all();
|
||||
}
|
||||
|
||||
export function reorderNotes(userId: number, orders: { id: string; sortOrder: number }[]) {
|
||||
export function reorderNotes(db: Db, userId: number, orders: { id: string; sortOrder: number }[]) {
|
||||
const now = new Date();
|
||||
for (const { id, sortOrder } of orders) {
|
||||
const { canAccess, isOwner } = canAccessNote(id, userId);
|
||||
const { canAccess, isOwner } = canAccessNote(db, id, userId);
|
||||
if (!canAccess) continue;
|
||||
|
||||
if (isOwner) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { getPreferences, upsertPreferences } from './preferences.js';
|
||||
|
||||
let db: Db;
|
||||
const USER_ID = 1;
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb({ seedUser: true });
|
||||
db = testDb.db;
|
||||
});
|
||||
|
||||
describe('getPreferences', () => {
|
||||
it('should return empty object for user with no preferences', () => {
|
||||
const prefs = getPreferences(db, USER_ID);
|
||||
expect(prefs).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertPreferences', () => {
|
||||
it('should insert new preferences', () => {
|
||||
const result = upsertPreferences(db, USER_ID, {
|
||||
defaultNoteMode: 'markdown',
|
||||
hideFooter: 'true'
|
||||
});
|
||||
expect(result).toEqual({
|
||||
defaultNoteMode: 'markdown',
|
||||
hideFooter: 'true'
|
||||
});
|
||||
});
|
||||
|
||||
it('should update existing preferences', () => {
|
||||
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
|
||||
const result = upsertPreferences(db, USER_ID, { defaultNoteMode: 'richtext' });
|
||||
expect(result.defaultNoteMode).toBe('richtext');
|
||||
});
|
||||
|
||||
it('should handle partial updates without affecting other keys', () => {
|
||||
upsertPreferences(db, USER_ID, {
|
||||
defaultNoteMode: 'markdown',
|
||||
hideFooter: 'true'
|
||||
});
|
||||
const result = upsertPreferences(db, USER_ID, { hideFooter: 'false' });
|
||||
expect(result).toEqual({
|
||||
defaultNoteMode: 'markdown',
|
||||
hideFooter: 'false'
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep preferences isolated between users', () => {
|
||||
// Create a second user
|
||||
db.run(
|
||||
/* sql */ `INSERT INTO users (email, display_name, role, auth_provider, created_at) VALUES ('user2@test.com', 'User 2', 'user', 'password', ${Date.now()})`
|
||||
);
|
||||
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
|
||||
upsertPreferences(db, 2, { defaultNoteMode: 'richtext' });
|
||||
|
||||
expect(getPreferences(db, USER_ID).defaultNoteMode).toBe('markdown');
|
||||
expect(getPreferences(db, 2).defaultNoteMode).toBe('richtext');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { userPreferences } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Get all preferences for a user as a key-value record.
|
||||
*/
|
||||
export function getPreferences(db: Db, userId: number): Record<string, string> {
|
||||
const rows = db
|
||||
.select({ key: userPreferences.key, value: userPreferences.value })
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, userId))
|
||||
.all();
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
result[row.key] = row.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert multiple preferences for a user.
|
||||
* Uses INSERT OR REPLACE with the unique (user_id, key) index.
|
||||
*/
|
||||
export function upsertPreferences(
|
||||
db: Db,
|
||||
userId: number,
|
||||
prefs: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const now = new Date();
|
||||
for (const [key, value] of Object.entries(prefs)) {
|
||||
db.insert(userPreferences)
|
||||
.values({ userId, key, value, updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.key],
|
||||
set: { value, updatedAt: now }
|
||||
})
|
||||
.run();
|
||||
}
|
||||
return getPreferences(db, userId);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { loginAttempts } from './db/schema.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { checkRateLimit, recordLoginAttempt, clearOldAttempts } from './rate-limit.js';
|
||||
|
||||
let db: Db;
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
});
|
||||
|
||||
const IP = '192.168.1.1';
|
||||
const EMAIL = 'test@test.com';
|
||||
|
||||
function addFailedAttempts(count: number, now: Date) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
recordLoginAttempt(db, IP, EMAIL, false, now);
|
||||
}
|
||||
}
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
it('should allow first attempt', () => {
|
||||
const result = checkRateLimit(db, IP, EMAIL);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow up to 4 failed attempts', () => {
|
||||
const now = new Date();
|
||||
addFailedAttempts(4, now);
|
||||
const result = checkRateLimit(db, IP, EMAIL, now);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should block after 5 failed attempts within window', () => {
|
||||
const now = new Date();
|
||||
addFailedAttempts(5, now);
|
||||
const result = checkRateLimit(db, IP, EMAIL, now);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.retryAfter).toBeDefined();
|
||||
expect(result.retryAfter!).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return correct retryAfter value', () => {
|
||||
const now = new Date();
|
||||
addFailedAttempts(5, now);
|
||||
const result = checkRateLimit(db, IP, EMAIL, now);
|
||||
// Lockout is 15 minutes (900 seconds)
|
||||
expect(result.retryAfter).toBe(900);
|
||||
});
|
||||
|
||||
it('should allow again after lockout expires', () => {
|
||||
const attemptTime = new Date('2024-01-01T00:00:00Z');
|
||||
addFailedAttempts(5, attemptTime);
|
||||
|
||||
// Check at attempt time — should be blocked
|
||||
const blocked = checkRateLimit(db, IP, EMAIL, attemptTime);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
// Check 16 minutes later — should be allowed
|
||||
const later = new Date(attemptTime.getTime() + 16 * 60 * 1000);
|
||||
const allowed = checkRateLimit(db, IP, EMAIL, later);
|
||||
expect(allowed.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordLoginAttempt', () => {
|
||||
it('should insert attempt record', () => {
|
||||
recordLoginAttempt(db, IP, EMAIL, false);
|
||||
const rows = db.select().from(loginAttempts).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ip).toBe(IP);
|
||||
expect(rows[0].email).toBe(EMAIL);
|
||||
});
|
||||
|
||||
it('should record success/failure flag correctly', () => {
|
||||
recordLoginAttempt(db, IP, EMAIL, true);
|
||||
recordLoginAttempt(db, IP, EMAIL, false);
|
||||
const rows = db.select().from(loginAttempts).all();
|
||||
expect(rows[0].success).toBe(true);
|
||||
expect(rows[1].success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearOldAttempts', () => {
|
||||
it('should remove attempts older than 2x lockout', () => {
|
||||
const old = new Date('2024-01-01T00:00:00Z');
|
||||
const recent = new Date('2024-01-01T01:00:00Z');
|
||||
recordLoginAttempt(db, IP, EMAIL, false, old);
|
||||
recordLoginAttempt(db, IP, EMAIL, false, recent);
|
||||
|
||||
// Clear at a time where old is > 2x lockout (30 min) ago but recent is not
|
||||
const clearTime = new Date(old.getTime() + 31 * 60 * 1000);
|
||||
clearOldAttempts(db, clearTime);
|
||||
|
||||
const rows = db.select().from(loginAttempts).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,19 @@
|
||||
import { db } from './db/index.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { loginAttempts } from './db/schema.js';
|
||||
import { and, eq, gte, sql } from 'drizzle-orm';
|
||||
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
export function checkRateLimit(
|
||||
db: Db,
|
||||
ip: string,
|
||||
email: string
|
||||
email: string,
|
||||
now?: Date
|
||||
): { allowed: boolean; retryAfter?: number } {
|
||||
// Skip rate limiting in test environment
|
||||
if (process.env.NODE_ENV === 'test' || process.env.PLAYWRIGHT_TEST === '1') {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const windowStart = new Date(Date.now() - LOCKOUT_MS);
|
||||
const currentTime = now ?? new Date();
|
||||
const windowStart = new Date(currentTime.getTime() - LOCKOUT_MS);
|
||||
|
||||
// Count failed attempts from this IP in the lockout window
|
||||
const result = db
|
||||
@@ -50,7 +48,7 @@ export function checkRateLimit(
|
||||
|
||||
if (latest) {
|
||||
const retryAfter = Math.ceil(
|
||||
(latest.timestamp.getTime() + LOCKOUT_MS - Date.now()) / 1000
|
||||
(latest.timestamp.getTime() + LOCKOUT_MS - currentTime.getTime()) / 1000
|
||||
);
|
||||
return { allowed: false, retryAfter: Math.max(retryAfter, 1) };
|
||||
}
|
||||
@@ -60,20 +58,20 @@ export function checkRateLimit(
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
export function recordLoginAttempt(ip: string, email: string, success: boolean): void {
|
||||
export function recordLoginAttempt(db: Db, ip: string, email: string, success: boolean, now?: Date): void {
|
||||
db.insert(loginAttempts)
|
||||
.values({
|
||||
ip,
|
||||
email,
|
||||
success,
|
||||
timestamp: new Date()
|
||||
timestamp: now ?? new Date()
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export function clearOldAttempts(): void {
|
||||
const cutoff = new Date(Date.now() - LOCKOUT_MS * 2);
|
||||
export function clearOldAttempts(db: Db, now?: Date): void {
|
||||
const cutoff = new Date((now ?? new Date()).getTime() - LOCKOUT_MS * 2);
|
||||
db.delete(loginAttempts)
|
||||
.where(sql`${loginAttempts.timestamp} < ${cutoff}`)
|
||||
.where(lt(loginAttempts.timestamp, cutoff))
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, tags, noteTags, users } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
|
||||
let db: Db;
|
||||
const USER_ID = 1;
|
||||
|
||||
function seedUser() {
|
||||
db.insert(users)
|
||||
.values({ email: 'test@test.com', displayName: 'Test', role: 'admin', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string) {
|
||||
db.insert(notes)
|
||||
.values({ id, userId: USER_ID, title: '', content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUser();
|
||||
});
|
||||
|
||||
describe('syncNoteTags', () => {
|
||||
it('should create new tags and associations', () => {
|
||||
seedNote('n1');
|
||||
syncNoteTags(db, 'n1', ['todo', 'urgent'], USER_ID);
|
||||
|
||||
const tagRows = db.select().from(tags).all();
|
||||
expect(tagRows).toHaveLength(2);
|
||||
|
||||
const associations = db.select().from(noteTags).all();
|
||||
expect(associations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should remove old associations and add new ones', () => {
|
||||
seedNote('n1');
|
||||
syncNoteTags(db, 'n1', ['old-tag'], USER_ID);
|
||||
syncNoteTags(db, 'n1', ['new-tag'], USER_ID);
|
||||
|
||||
const map = fetchTagsForNotes(db, ['n1']);
|
||||
expect(map.get('n1')).toEqual(['new-tag']);
|
||||
});
|
||||
|
||||
it('should handle empty tag list (removes all)', () => {
|
||||
seedNote('n1');
|
||||
syncNoteTags(db, 'n1', ['tag1', 'tag2'], USER_ID);
|
||||
syncNoteTags(db, 'n1', [], USER_ID);
|
||||
|
||||
const associations = db.select().from(noteTags).where(eq(noteTags.noteId, 'n1')).all();
|
||||
expect(associations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should deduplicate existing tags', () => {
|
||||
seedNote('n1');
|
||||
seedNote('n2');
|
||||
syncNoteTags(db, 'n1', ['shared-tag'], USER_ID);
|
||||
syncNoteTags(db, 'n2', ['shared-tag'], USER_ID);
|
||||
|
||||
// Should reuse the same tag row
|
||||
const tagRows = db.select().from(tags).all();
|
||||
expect(tagRows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTagsForNotes', () => {
|
||||
it('should return Map of noteId to tag names', () => {
|
||||
seedNote('n1');
|
||||
seedNote('n2');
|
||||
syncNoteTags(db, 'n1', ['alpha', 'beta'], USER_ID);
|
||||
syncNoteTags(db, 'n2', ['gamma'], USER_ID);
|
||||
|
||||
const map = fetchTagsForNotes(db, ['n1', 'n2']);
|
||||
expect(map.get('n1')).toHaveLength(2);
|
||||
expect(map.get('n1')).toContain('alpha');
|
||||
expect(map.get('n1')).toContain('beta');
|
||||
expect(map.get('n2')).toEqual(['gamma']);
|
||||
});
|
||||
|
||||
it('should return empty map for empty noteIds', () => {
|
||||
const map = fetchTagsForNotes(db, []);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db } from './db/index.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteTags, tags } from './db/schema.js';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { eq, and, inArray } from 'drizzle-orm';
|
||||
* Batch fetch tags for multiple notes in a single query.
|
||||
* Returns a Map of noteId → tag names.
|
||||
*/
|
||||
export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
|
||||
export function fetchTagsForNotes(db: Db, noteIds: string[]): Map<string, string[]> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
@@ -32,7 +32,7 @@ export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
|
||||
* Sync tags for a note: removes old associations, upserts tags, creates new associations.
|
||||
* Should be called within a transaction for atomicity.
|
||||
*/
|
||||
export function syncNoteTags(noteId: string, tagNames: string[], userId: number) {
|
||||
export function syncNoteTags(db: Db, noteId: string, tagNames: string[], userId: number) {
|
||||
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
|
||||
|
||||
for (const name of tagNames) {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { users, notes, noteVersions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote } from './versions-service.js';
|
||||
|
||||
let db: Db;
|
||||
|
||||
const OWNER_ID = 1;
|
||||
const NOTE_ID = 'note-history-test';
|
||||
|
||||
function seedUser() {
|
||||
db.insert(users)
|
||||
.values({
|
||||
id: OWNER_ID,
|
||||
email: 'owner@test.com',
|
||||
displayName: 'Owner',
|
||||
role: 'admin',
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(overrides: Partial<typeof notes.$inferInsert> = {}) {
|
||||
const now = new Date();
|
||||
db.insert(notes)
|
||||
.values({
|
||||
id: NOTE_ID,
|
||||
userId: OWNER_ID,
|
||||
title: 'Test Note',
|
||||
content: 'Test content',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1,
|
||||
...overrides
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
seedUser();
|
||||
});
|
||||
|
||||
describe('createSnapshot', () => {
|
||||
it('should store a snapshot for a note', () => {
|
||||
seedNote();
|
||||
createSnapshot(db, NOTE_ID, {
|
||||
version: 1,
|
||||
title: 'Test Note',
|
||||
content: 'Test content',
|
||||
checklistMode: false,
|
||||
color: 'default'
|
||||
});
|
||||
|
||||
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].version).toBe(1);
|
||||
expect(rows[0].title).toBe('Test Note');
|
||||
expect(rows[0].content).toBe('Test content');
|
||||
expect(rows[0].checklistMode).toBe(false);
|
||||
expect(rows[0].color).toBe('default');
|
||||
});
|
||||
|
||||
it('should not duplicate snapshots for the same version (onConflictDoNothing)', () => {
|
||||
seedNote();
|
||||
createSnapshot(db, NOTE_ID, { version: 1, title: 'A', content: '', checklistMode: false, color: 'default' });
|
||||
createSnapshot(db, NOTE_ID, { version: 1, title: 'B', content: '', checklistMode: false, color: 'default' });
|
||||
|
||||
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].title).toBe('A'); // first one wins
|
||||
});
|
||||
|
||||
it('should prune to at most 50 snapshots', () => {
|
||||
seedNote();
|
||||
for (let i = 1; i <= 55; i++) {
|
||||
createSnapshot(db, NOTE_ID, {
|
||||
version: i,
|
||||
title: `Version ${i}`,
|
||||
content: '',
|
||||
checklistMode: false,
|
||||
color: 'default'
|
||||
});
|
||||
}
|
||||
|
||||
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
|
||||
expect(rows).toHaveLength(50);
|
||||
// Oldest (v1–v5) should be pruned; newest (v6–v55) should remain
|
||||
const versions = rows.map((r) => r.version);
|
||||
expect(Math.min(...versions)).toBe(6);
|
||||
expect(Math.max(...versions)).toBe(55);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listVersions', () => {
|
||||
it('should return an empty array when there are no versions', () => {
|
||||
seedNote();
|
||||
expect(listVersions(db, NOTE_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return summaries sorted newest first', () => {
|
||||
seedNote();
|
||||
createSnapshot(db, NOTE_ID, { version: 1, title: 'First', content: 'Hello world', checklistMode: false, color: 'default' });
|
||||
createSnapshot(db, NOTE_ID, { version: 2, title: 'Second', content: 'Updated content here', checklistMode: false, color: 'mint' });
|
||||
|
||||
const summaries = listVersions(db, NOTE_ID);
|
||||
expect(summaries).toHaveLength(2);
|
||||
expect(summaries[0].version).toBe(2); // newest first
|
||||
expect(summaries[1].version).toBe(1);
|
||||
});
|
||||
|
||||
it('should truncate contentPreview to 80 characters', () => {
|
||||
seedNote();
|
||||
const longContent = 'a'.repeat(200);
|
||||
createSnapshot(db, NOTE_ID, { version: 1, title: 'T', content: longContent, checklistMode: false, color: 'default' });
|
||||
|
||||
const summaries = listVersions(db, NOTE_ID);
|
||||
expect(summaries[0].contentPreview).toHaveLength(80);
|
||||
});
|
||||
|
||||
it('should include correct summary fields', () => {
|
||||
seedNote();
|
||||
createSnapshot(db, NOTE_ID, { version: 3, title: 'My Title', content: 'My content', checklistMode: false, color: 'coral' });
|
||||
|
||||
const summaries = listVersions(db, NOTE_ID);
|
||||
expect(summaries[0]).toMatchObject({
|
||||
noteId: NOTE_ID,
|
||||
version: 3,
|
||||
title: 'My Title',
|
||||
contentPreview: 'My content'
|
||||
});
|
||||
expect(summaries[0].id).toBeTruthy();
|
||||
expect(summaries[0].createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVersion', () => {
|
||||
it('should return null for a non-existent version', () => {
|
||||
seedNote();
|
||||
expect(getVersion(db, NOTE_ID, 'non-existent-id')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the full version content', () => {
|
||||
seedNote();
|
||||
createSnapshot(db, NOTE_ID, {
|
||||
version: 2,
|
||||
title: 'Full Title',
|
||||
content: '# Heading\n\nSome content',
|
||||
checklistMode: true,
|
||||
color: 'mint'
|
||||
});
|
||||
|
||||
const summaries = listVersions(db, NOTE_ID);
|
||||
const versionId = summaries[0].id;
|
||||
|
||||
const version = getVersion(db, NOTE_ID, versionId);
|
||||
expect(version).not.toBeNull();
|
||||
expect(version!.version).toBe(2);
|
||||
expect(version!.title).toBe('Full Title');
|
||||
expect(version!.content).toBe('# Heading\n\nSome content');
|
||||
expect(version!.checklistMode).toBe(true);
|
||||
expect(version!.color).toBe('mint');
|
||||
});
|
||||
|
||||
it('should not return a version from a different note', () => {
|
||||
const OTHER_NOTE_ID = 'other-note';
|
||||
seedNote();
|
||||
db.insert(notes).values({
|
||||
id: OTHER_NOTE_ID,
|
||||
userId: OWNER_ID,
|
||||
title: 'Other',
|
||||
content: '',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
version: 1
|
||||
}).run();
|
||||
|
||||
createSnapshot(db, OTHER_NOTE_ID, { version: 1, title: 'Other', content: 'Other content', checklistMode: false, color: 'default' });
|
||||
const summaries = listVersions(db, OTHER_NOTE_ID);
|
||||
const versionId = summaries[0].id;
|
||||
|
||||
// Requesting version from wrong noteId returns null
|
||||
expect(getVersion(db, NOTE_ID, versionId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshotCurrentNote', () => {
|
||||
it('should return false for a non-existent note', () => {
|
||||
expect(snapshotCurrentNote(db, 'non-existent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should snapshot the current note state', () => {
|
||||
seedNote({ title: 'Current Title', content: 'Current content', version: 5 });
|
||||
|
||||
const result = snapshotCurrentNote(db, NOTE_ID);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].version).toBe(5);
|
||||
expect(rows[0].title).toBe('Current Title');
|
||||
expect(rows[0].content).toBe('Current content');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteVersions, notes } from './db/schema.js';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { NoteVersion, NoteVersionSummary, NoteColor } from '$lib/types/index.js';
|
||||
|
||||
const MAX_VERSIONS_PER_NOTE = 50;
|
||||
const CONTENT_PREVIEW_LENGTH = 80;
|
||||
|
||||
/**
|
||||
* Create a snapshot of a note's current state.
|
||||
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
|
||||
*/
|
||||
export function createSnapshot(
|
||||
db: Db,
|
||||
noteId: string,
|
||||
snapshot: {
|
||||
version: number;
|
||||
title: string;
|
||||
content: string;
|
||||
checklistMode: boolean;
|
||||
color: string;
|
||||
}
|
||||
): void {
|
||||
const now = new Date();
|
||||
|
||||
db.insert(noteVersions)
|
||||
.values({
|
||||
id: uuidv4(),
|
||||
noteId,
|
||||
version: snapshot.version,
|
||||
title: snapshot.title,
|
||||
content: snapshot.content,
|
||||
checklistMode: snapshot.checklistMode,
|
||||
color: snapshot.color,
|
||||
createdAt: now
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
|
||||
// Prune: keep only the most recent MAX_VERSIONS_PER_NOTE snapshots
|
||||
const allVersions = db
|
||||
.select({ id: noteVersions.id })
|
||||
.from(noteVersions)
|
||||
.where(eq(noteVersions.noteId, noteId))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.all();
|
||||
|
||||
if (allVersions.length > MAX_VERSIONS_PER_NOTE) {
|
||||
const toDelete = allVersions.slice(MAX_VERSIONS_PER_NOTE).map((v) => v.id);
|
||||
for (const id of toDelete) {
|
||||
db.delete(noteVersions).where(eq(noteVersions.id, id)).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List version summaries for a note, newest first.
|
||||
*/
|
||||
export function listVersions(db: Db, noteId: string): NoteVersionSummary[] {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(noteVersions)
|
||||
.where(eq(noteVersions.noteId, noteId))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.all();
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
noteId: row.noteId,
|
||||
version: row.version,
|
||||
title: row.title,
|
||||
contentPreview: row.content.slice(0, CONTENT_PREVIEW_LENGTH),
|
||||
createdAt: row.createdAt
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full content of a specific version.
|
||||
*/
|
||||
export function getVersion(db: Db, noteId: string, versionId: string): NoteVersion | null {
|
||||
const row = db
|
||||
.select()
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.id, versionId), eq(noteVersions.noteId, noteId)))
|
||||
.get();
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
noteId: row.noteId,
|
||||
version: row.version,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
checklistMode: row.checklistMode,
|
||||
color: row.color as NoteColor,
|
||||
createdAt: row.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the current state of a note (used before a restore).
|
||||
*/
|
||||
export function snapshotCurrentNote(db: Db, noteId: string): boolean {
|
||||
const currentNote = db.select().from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (!currentNote) return false;
|
||||
|
||||
createSnapshot(db, noteId, {
|
||||
version: currentNote.version,
|
||||
title: currentNote.title,
|
||||
content: currentNote.content,
|
||||
checklistMode: currentNote.checklistMode,
|
||||
color: currentNote.color
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js';
|
||||
import type { UserPreferences } from '$lib/types/preferences.js';
|
||||
|
||||
const STORAGE_KEY = 'crumbs-preferences';
|
||||
|
||||
let preferences = $state<UserPreferences>({ ...DEFAULT_PREFERENCES });
|
||||
|
||||
function loadFromLocalStorage(): UserPreferences {
|
||||
if (!browser) return { ...DEFAULT_PREFERENCES };
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
return { ...DEFAULT_PREFERENCES, ...parsed };
|
||||
}
|
||||
} catch {
|
||||
// Corrupted localStorage — use defaults
|
||||
}
|
||||
return { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
|
||||
function saveToLocalStorage(prefs: UserPreferences) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
// localStorage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert server key-value record to typed UserPreferences */
|
||||
function fromServerRecord(record: Record<string, string>): Partial<UserPreferences> {
|
||||
const result: Partial<UserPreferences> = {};
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (key in DEFAULT_PREFERENCES) {
|
||||
const k = key as keyof UserPreferences;
|
||||
if (BOOLEAN_PREF_KEYS.has(k)) {
|
||||
(result as Record<string, unknown>)[k] = value === 'true';
|
||||
} else {
|
||||
(result as Record<string, unknown>)[k] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Convert UserPreferences to server key-value record */
|
||||
function toServerRecord(prefs: Partial<UserPreferences>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(prefs)) {
|
||||
result[key] = String(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize preferences — load from localStorage, then sync from server.
|
||||
*/
|
||||
export function initPreferences() {
|
||||
preferences = loadFromLocalStorage();
|
||||
syncPreferencesFromServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reactive preferences object.
|
||||
*/
|
||||
export function getPreferences(): UserPreferences {
|
||||
return preferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single preference. Saves to localStorage and pushes to server.
|
||||
*/
|
||||
export function updatePreference<K extends keyof UserPreferences>(
|
||||
key: K,
|
||||
value: UserPreferences[K]
|
||||
) {
|
||||
preferences = { ...preferences, [key]: value };
|
||||
saveToLocalStorage(preferences);
|
||||
pushToServer({ [key]: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull preferences from server and merge into local state.
|
||||
*/
|
||||
export async function syncPreferencesFromServer() {
|
||||
if (!browser) return;
|
||||
try {
|
||||
const res = await fetch('/api/preferences');
|
||||
if (res.ok) {
|
||||
const serverPrefs: Record<string, string> = await res.json();
|
||||
const parsed = fromServerRecord(serverPrefs);
|
||||
preferences = { ...DEFAULT_PREFERENCES, ...parsed };
|
||||
saveToLocalStorage(preferences);
|
||||
}
|
||||
} catch {
|
||||
// Offline — local state stands
|
||||
}
|
||||
}
|
||||
|
||||
/** Push preference changes to server */
|
||||
async function pushToServer(partial: Partial<UserPreferences>) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
await fetch('/api/preferences', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(toServerRecord(partial))
|
||||
});
|
||||
} catch {
|
||||
// Offline — localStorage already updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js';
|
||||
|
||||
describe('preferences defaults', () => {
|
||||
it('should have correct default values', () => {
|
||||
expect(DEFAULT_PREFERENCES).toEqual({
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
sidebarDefaultState: 'open',
|
||||
notifyOnShare: true,
|
||||
notifyOnCollabRemoved: true,
|
||||
notifyOnNoteDeleted: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should identify boolean preference keys', () => {
|
||||
expect(BOOLEAN_PREF_KEYS.has('hideFooter')).toBe(true);
|
||||
expect(BOOLEAN_PREF_KEYS.has('notifyOnShare')).toBe(true);
|
||||
expect(BOOLEAN_PREF_KEYS.has('notifyOnCollabRemoved')).toBe(true);
|
||||
expect(BOOLEAN_PREF_KEYS.has('notifyOnNoteDeleted')).toBe(true);
|
||||
expect(BOOLEAN_PREF_KEYS.has('defaultNoteMode')).toBe(false);
|
||||
expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false);
|
||||
expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false);
|
||||
});
|
||||
});
|
||||
+19
-19
@@ -51,7 +51,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1 });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].id).toBe('n1');
|
||||
@@ -65,7 +65,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const changes = await getChangesSince(t2.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t2.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(0);
|
||||
});
|
||||
@@ -86,7 +86,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
sortOrder: 5
|
||||
}).run();
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
// Should reflect the collaborator's per-user state, not the owner's
|
||||
@@ -102,7 +102,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
// Owner has pinned=true, sortOrder=10
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, pinned: true, sortOrder: 10 });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
// Without noteUserState, collaborator should get defaults (false/0), not owner's values
|
||||
@@ -116,7 +116,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, trashed: true });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(0);
|
||||
});
|
||||
@@ -133,7 +133,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
.run();
|
||||
|
||||
// Collaborator syncs since t1
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].content).toBe('Updated by owner');
|
||||
@@ -157,7 +157,7 @@ describe('getChangesSince — shared notes', () => {
|
||||
// Note shared with COLLAB_ID (owned by OWNER_ID)
|
||||
seedSharedNote('shared-with-collab', { updatedAt: t2, createdAt: t1 });
|
||||
|
||||
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
|
||||
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
|
||||
|
||||
expect(changes).toHaveLength(2);
|
||||
const ids = changes.map((c) => c.id).sort();
|
||||
@@ -171,12 +171,12 @@ describe('processSyncPush — shared notes', () => {
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { title: 'Updated by collab', content: 'New content' }
|
||||
}], COLLAB_ID, db);
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Updated by collab');
|
||||
@@ -189,12 +189,12 @@ describe('processSyncPush — shared notes', () => {
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { pinned: true, sortOrder: 3 }
|
||||
}], COLLAB_ID, db);
|
||||
}], COLLAB_ID);
|
||||
|
||||
// Per-user fields should be in noteUserState, not in notes
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
@@ -213,12 +213,12 @@ describe('processSyncPush — shared notes', () => {
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { trashed: true }
|
||||
}], COLLAB_ID, db);
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.trashed).toBe(false);
|
||||
@@ -230,12 +230,12 @@ describe('processSyncPush — shared notes', () => {
|
||||
|
||||
// Collaborator's change has a timestamp BEFORE the server's updatedAt
|
||||
const t1 = new Date('2024-01-01T12:00:00Z').getTime();
|
||||
await processSyncPush([{
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1,
|
||||
data: { title: 'Stale edit' }
|
||||
}], COLLAB_ID, db);
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Shared Note'); // Unchanged
|
||||
@@ -263,12 +263,12 @@ describe('processSyncPush — shared notes', () => {
|
||||
}).run();
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { title: 'Hacked' }
|
||||
}], 3, db); // User 3 is not owner or collaborator
|
||||
}], 3); // User 3 is not owner or collaborator
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Private'); // Unchanged
|
||||
@@ -279,12 +279,12 @@ describe('processSyncPush — shared notes', () => {
|
||||
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
|
||||
|
||||
const t2 = t1.getTime() + 1000;
|
||||
await processSyncPush([{
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t2,
|
||||
data: { title: 'Updated' }
|
||||
}], COLLAB_ID, db);
|
||||
}], COLLAB_ID);
|
||||
|
||||
const logs = db.select().from(syncLog).where(eq(syncLog.noteId, 'n1')).all();
|
||||
expect(logs).toHaveLength(1);
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { db as defaultDb } from '$lib/server/db/index.js';
|
||||
import type { Db } from '$lib/server/db/index.js';
|
||||
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, or, sql } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { syncNoteTags } from '$lib/server/tags.js';
|
||||
import { canAccessNote } from '$lib/server/api-utils.js';
|
||||
import type { SyncQueueItem } from './idb.js';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyDb = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<any>;
|
||||
|
||||
/**
|
||||
* Process incoming sync changes from client.
|
||||
*/
|
||||
export async function processSyncPush(changes: SyncQueueItem[], userId: number, injectedDb?: AnyDb): Promise<void> {
|
||||
const db = injectedDb ?? defaultDb;
|
||||
export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId: number): Promise<void> {
|
||||
const noteIdsToSyncTags: string[] = [];
|
||||
|
||||
db.transaction((tx) => {
|
||||
@@ -140,7 +136,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number,
|
||||
const note = db.select().from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (note) {
|
||||
const content = `${note.title} ${note.content}`;
|
||||
syncNoteTags(noteId, extractTags(content), note.userId);
|
||||
syncNoteTags(db, noteId, extractTags(content), note.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,8 +147,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number,
|
||||
* For shared notes, per-user state (pinned, archived, sortOrder) is overlaid
|
||||
* from noteUserState so collaborators get their own view.
|
||||
*/
|
||||
export async function getChangesSince(sinceTimestamp: number, userId: number, injectedDb?: AnyDb) {
|
||||
const db = injectedDb ?? defaultDb;
|
||||
export async function getChangesSince(db: Db, sinceTimestamp: number, userId: number) {
|
||||
const since = new Date(sinceTimestamp);
|
||||
|
||||
// Owned notes
|
||||
|
||||
@@ -97,3 +97,26 @@ export type NoteColor =
|
||||
export type NoteFilter = 'all' | 'archived' | 'trashed';
|
||||
|
||||
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
|
||||
|
||||
export interface NoteVersionSummary {
|
||||
id: string;
|
||||
noteId: string;
|
||||
version: number;
|
||||
title: string;
|
||||
contentPreview: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface NoteVersion {
|
||||
id: string;
|
||||
noteId: string;
|
||||
version: number;
|
||||
title: string;
|
||||
content: string;
|
||||
checklistMode: boolean;
|
||||
color: NoteColor;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type { UserPreferences } from './preferences.js';
|
||||
export { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from './preferences.js';
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { NoteColor } from './index.js';
|
||||
|
||||
export interface UserPreferences {
|
||||
defaultNoteMode: 'richtext' | 'markdown';
|
||||
defaultNoteColor: NoteColor;
|
||||
hideFooter: boolean;
|
||||
sidebarDefaultState: 'open' | 'collapsed';
|
||||
notifyOnShare: boolean;
|
||||
notifyOnCollabRemoved: boolean;
|
||||
notifyOnNoteDeleted: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
sidebarDefaultState: 'open',
|
||||
notifyOnShare: true,
|
||||
notifyOnCollabRemoved: true,
|
||||
notifyOnNoteDeleted: true
|
||||
};
|
||||
|
||||
export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([
|
||||
'hideFooter',
|
||||
'notifyOnShare',
|
||||
'notifyOnCollabRemoved',
|
||||
'notifyOnNoteDeleted'
|
||||
]);
|
||||
@@ -24,6 +24,17 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||
const token = new state.Token('html_inline', '', 0);
|
||||
token.content = checkbox;
|
||||
tokens[i].children!.unshift(token);
|
||||
|
||||
// Add task-list class to parent <ul> (once)
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (tokens[j].type === 'bullet_list_open') {
|
||||
const cls = tokens[j].attrGet('class') ?? '';
|
||||
if (!cls.includes('task-list')) {
|
||||
tokens[j].attrJoin('class', 'task-list');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,13 +5,21 @@
|
||||
import { loadNotes } from '$lib/stores/notes.js';
|
||||
import { startSync, stopSync } from '$lib/sync/client.js';
|
||||
import { initDb } from '$lib/sync/idb.js';
|
||||
import { initPreferences, getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let sidebarOpen = $state(false);
|
||||
const prefs = $derived(getPreferences());
|
||||
|
||||
onMount(() => {
|
||||
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
|
||||
initPreferences();
|
||||
const prefState = getPreferences();
|
||||
if (prefState.sidebarDefaultState === 'collapsed') {
|
||||
sidebarOpen = false;
|
||||
} else {
|
||||
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
|
||||
}
|
||||
if (data.user) {
|
||||
initDb(data.user.id);
|
||||
}
|
||||
@@ -26,7 +34,7 @@
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-[var(--bg-base)] text-[var(--text)]">
|
||||
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
|
||||
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} appVersion={data.appVersion} />
|
||||
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} />
|
||||
|
||||
<main class="flex-1 pt-4 transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
|
||||
<div class="mx-auto max-w-7xl px-4">
|
||||
@@ -34,9 +42,11 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}">
|
||||
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> — made with 🥨 in Strasbourg
|
||||
</footer>
|
||||
{#if !prefs.hideFooter}
|
||||
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}" data-testid="app-footer">
|
||||
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> — made with 🥨 in Strasbourg
|
||||
</footer>
|
||||
{/if}
|
||||
|
||||
<Toast />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { User, Cpu, ShieldCheck } from 'lucide-svelte';
|
||||
import { User, Cpu, ShieldCheck, SlidersHorizontal, Info } from 'lucide-svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let pathname = $derived(page.url.pathname);
|
||||
@@ -19,8 +19,18 @@
|
||||
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
|
||||
<li>
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
href="/settings/preferences"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/preferences' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
data-testid="settings-nav-preferences"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
Preferences
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/settings/profile"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/profile' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<User size={16} />
|
||||
Profile
|
||||
@@ -32,7 +42,7 @@
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/mcp' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Cpu size={16} />
|
||||
MCP Server
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
{#if data.user?.role === 'admin'}
|
||||
@@ -46,6 +56,15 @@
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<a
|
||||
href="/settings/about"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/about' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Info size={16} />
|
||||
About
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function load() {
|
||||
redirect(302, '/settings/preferences');
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let appVersion = $derived($page.data.appVersion);
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-xl font-bold text-[var(--text)]">About Crumbs</h2>
|
||||
<span
|
||||
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-2 py-0.5 text-xs font-medium text-[var(--text-muted)]"
|
||||
>
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-[var(--text)]">What is Crumbs?</h3>
|
||||
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
Crumbs is a self-hostable note-taking app. Like breadcrumbs left along a trail, Crumbs
|
||||
captures the small scattered thoughts, ideas, and notes you leave behind as you go — so you
|
||||
can always find your way back to them.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-[var(--text)]">Bretzel App Universe</h3>
|
||||
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
Crumbs is part of the
|
||||
<a
|
||||
href="https://bretzel.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">Bretzel</a
|
||||
> family of apps.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-[var(--text)]">Open Source</h3>
|
||||
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
Crumbs is licensed under the MIT License. Source code is available on
|
||||
<a
|
||||
href="https://github.com/bretzel-app/crumbs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">GitHub</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-[var(--text)]">Feedback</h3>
|
||||
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
Feedback is welcome! Please open an issue on
|
||||
<a
|
||||
href="https://github.com/bretzel-app/crumbs/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">GitHub Issues</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-[var(--text)]">Tech Stack</h3>
|
||||
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
Built with SvelteKit, SQLite, and Drizzle ORM. Offline-first PWA with CRDT-based sync.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -122,7 +122,7 @@ ${mcpJsonConfig}`);
|
||||
<h2 class="text-lg font-semibold text-[var(--text)]">API Keys</h2>
|
||||
</div>
|
||||
<p class="mb-4 text-xs text-[var(--text-muted)]">
|
||||
API keys allow external tools (like Claude Code) to access your notes via the MCP protocol.
|
||||
API keys allow external tools to access your notes via the MCP protocol and the REST API.
|
||||
</p>
|
||||
|
||||
<!-- Create Key Form -->
|
||||
@@ -229,9 +229,19 @@ ${mcpJsonConfig}`);
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- REST API Example -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">REST API</h2>
|
||||
<p class="mb-3 text-xs text-[var(--text-muted)]">
|
||||
Use your API key to authenticate requests to any <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">/api/*</code> endpoint:
|
||||
</p>
|
||||
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 text-xs text-[var(--text)]">curl -H "Authorization: Bearer {activeApiKey}" \
|
||||
{baseUrl}/api/notes</pre>
|
||||
</section>
|
||||
|
||||
<!-- MCP Config Help -->
|
||||
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Client Configuration</h2>
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">MCP Client Configuration</h2>
|
||||
<p class="mb-3 text-xs text-[var(--text-muted)]">
|
||||
Add this to your <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">.claude/settings.json</code> (Claude Code):
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import ColorPicker from '$lib/components/ColorPicker.svelte';
|
||||
import { getPreferences, updatePreference } from '$lib/stores/preferences.svelte.js';
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
|
||||
const prefs = $derived(getPreferences());
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<h2 class="text-xl font-bold text-[var(--text)]">Preferences</h2>
|
||||
|
||||
<!-- Default note mode -->
|
||||
<div class="space-y-2">
|
||||
<span class="block text-sm font-medium text-[var(--text)]">Default note mode</span>
|
||||
<div class="flex gap-1" role="group" aria-label="Default note mode">
|
||||
<button
|
||||
onclick={() => updatePreference('defaultNoteMode', 'richtext')}
|
||||
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.defaultNoteMode === 'richtext' ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
|
||||
data-testid="pref-mode-richtext"
|
||||
>
|
||||
Rich text
|
||||
</button>
|
||||
<button
|
||||
onclick={() => updatePreference('defaultNoteMode', 'markdown')}
|
||||
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.defaultNoteMode === 'markdown' ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
|
||||
data-testid="pref-mode-markdown"
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default note color -->
|
||||
<div class="space-y-2">
|
||||
<span class="block text-sm font-medium text-[var(--text)]">Default note color</span>
|
||||
<ColorPicker
|
||||
selected={prefs.defaultNoteColor}
|
||||
onSelect={(color: NoteColor) => updatePreference('defaultNoteColor', color)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Hide footer -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="hide-footer"
|
||||
checked={prefs.hideFooter}
|
||||
onchange={() => updatePreference('hideFooter', !prefs.hideFooter)}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
data-testid="pref-hide-footer"
|
||||
/>
|
||||
<label for="hide-footer" class="text-sm text-[var(--text)]">Hide footer</label>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar default state -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="sidebar-default"
|
||||
checked={prefs.sidebarDefaultState === 'collapsed'}
|
||||
onchange={() => updatePreference('sidebarDefaultState', prefs.sidebarDefaultState === 'collapsed' ? 'open' : 'collapsed')}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
data-testid="pref-sidebar-default"
|
||||
/>
|
||||
<label for="sidebar-default" class="text-sm text-[var(--text)]">Start with sidebar collapsed</label>
|
||||
</div>
|
||||
|
||||
<!-- Email notifications -->
|
||||
<div class="space-y-3">
|
||||
<span class="block text-sm font-medium text-[var(--text)]">Email notifications</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="notify-on-share"
|
||||
checked={prefs.notifyOnShare}
|
||||
onchange={() => updatePreference('notifyOnShare', !prefs.notifyOnShare)}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
data-testid="pref-notify-on-share"
|
||||
/>
|
||||
<label for="notify-on-share" class="text-sm text-[var(--text)]">Email me when someone shares a note with me</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="notify-on-collab-removed"
|
||||
checked={prefs.notifyOnCollabRemoved}
|
||||
onchange={() => updatePreference('notifyOnCollabRemoved', !prefs.notifyOnCollabRemoved)}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
data-testid="pref-notify-on-collab-removed"
|
||||
/>
|
||||
<label for="notify-on-collab-removed" class="text-sm text-[var(--text)]">Email me when I'm removed from a shared note</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="notify-on-note-deleted"
|
||||
checked={prefs.notifyOnNoteDeleted}
|
||||
onchange={() => updatePreference('notifyOnNoteDeleted', !prefs.notifyOnNoteDeleted)}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
data-testid="pref-notify-on-note-deleted"
|
||||
/>
|
||||
<label for="notify-on-note-deleted" class="text-sm text-[var(--text)]">Email me when a shared note I have access to is deleted</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { requireAdmin } from '$lib/server/api-utils.js';
|
||||
import { listUsers, createUser } from '$lib/server/auth.js';
|
||||
import { isEmailConfigured, sendWelcomeEmail } from '$lib/server/email.js';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
@@ -21,5 +22,12 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
}
|
||||
|
||||
const user = await createUser(email, displayName || email.split('@')[0], password || null, role || 'user');
|
||||
|
||||
// Send welcome email (fire-and-forget)
|
||||
if (isEmailConfigured() && user.email) {
|
||||
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||
sendWelcomeEmail(user.email, user.displayName, origin).catch(() => {});
|
||||
}
|
||||
|
||||
return json(user, { status: 201 });
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { requireAdmin } from '$lib/server/api-utils.js';
|
||||
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js';
|
||||
import { isEmailConfigured, sendPasswordResetEmail, sendRoleChangedEmail } from '$lib/server/email.js';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const admin = requireAdmin(event);
|
||||
@@ -11,12 +12,19 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const user = getUser(userId);
|
||||
if (!user) throw error(404, 'User not found');
|
||||
|
||||
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||
|
||||
if (body.role !== undefined) {
|
||||
// Prevent admin from demoting themselves
|
||||
if (userId === admin.id && body.role !== 'admin') {
|
||||
throw error(400, 'Cannot change your own role');
|
||||
}
|
||||
updateUserRole(userId, body.role);
|
||||
|
||||
// Notify user of role change (fire-and-forget)
|
||||
if (isEmailConfigured() && user.email) {
|
||||
sendRoleChangedEmail(user.email, user.displayName, body.role, origin).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (body.newPassword !== undefined) {
|
||||
@@ -24,6 +32,11 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
throw error(400, 'Password must be at least 8 characters');
|
||||
}
|
||||
await resetPassword(userId, body.newPassword);
|
||||
|
||||
// Notify user of password reset (fire-and-forget)
|
||||
if (isEmailConfigured() && user.email) {
|
||||
sendPasswordResetEmail(user.email, user.displayName, origin).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (body.revokeSessions) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { verifyPassword, deleteUser, deleteSession, getUser } from '$lib/server/auth.js';
|
||||
import { isEmailConfigured, sendAccountDeletedEmail } from '$lib/server/email.js';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
@@ -17,6 +18,9 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
|
||||
if (!verified) throw error(401, 'Invalid password');
|
||||
}
|
||||
|
||||
// Capture email/name before deletion
|
||||
const { email: userEmail, displayName: userName } = user;
|
||||
|
||||
// Delete user and all associated data
|
||||
await deleteUser(userId);
|
||||
|
||||
@@ -27,5 +31,10 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
|
||||
cookies.delete('session', { path: '/' });
|
||||
}
|
||||
|
||||
// Send deletion confirmation email (fire-and-forget)
|
||||
if (isEmailConfigured() && userEmail) {
|
||||
sendAccountDeletedEmail(userEmail, userName).catch(() => {});
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -2,6 +2,10 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { verifyPassword, createSession } from '$lib/server/auth.js';
|
||||
import { checkRateLimit, recordLoginAttempt } from '$lib/server/rate-limit.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { users } from '$lib/server/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { isEmailConfigured, sendAccountLockedEmail } from '$lib/server/email.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
|
||||
const ip = getClientAddress();
|
||||
@@ -11,7 +15,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
|
||||
throw error(400, 'Email and password are required');
|
||||
}
|
||||
|
||||
const rateCheck = checkRateLimit(ip, email);
|
||||
const rateCheck = checkRateLimit(db, ip, email);
|
||||
if (!rateCheck.allowed) {
|
||||
return json(
|
||||
{ error: 'Too many login attempts. Please try again later.', retryAfter: rateCheck.retryAfter },
|
||||
@@ -21,11 +25,32 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
|
||||
|
||||
const user = await verifyPassword(email, password);
|
||||
if (!user) {
|
||||
recordLoginAttempt(ip, email, false);
|
||||
recordLoginAttempt(db, ip, email, false);
|
||||
|
||||
// Check if this failure just triggered a lockout — if so, notify the user
|
||||
if (isEmailConfigured()) {
|
||||
const newCheck = checkRateLimit(db, ip, email);
|
||||
if (!newCheck.allowed) {
|
||||
const retryAfterMinutes = Math.ceil((newCheck.retryAfter ?? 900) / 60);
|
||||
const accountUser = db
|
||||
.select({ email: users.email, displayName: users.displayName })
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.get();
|
||||
if (accountUser?.email) {
|
||||
sendAccountLockedEmail(
|
||||
accountUser.email,
|
||||
accountUser.displayName,
|
||||
retryAfterMinutes
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error(401, 'Invalid email or password');
|
||||
}
|
||||
|
||||
recordLoginAttempt(ip, email, true);
|
||||
recordLoginAttempt(db, ip, email, true);
|
||||
|
||||
const userAgent = request.headers.get('user-agent') || undefined;
|
||||
const token = await createSession(user.id, { userAgent, ip });
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { updateUserProfile } from '$lib/server/auth.js';
|
||||
import { updateUserProfile, getUser } from '$lib/server/auth.js';
|
||||
import { isEmailConfigured, sendEmailChangedEmail } from '$lib/server/email.js';
|
||||
|
||||
export const PUT: RequestHandler = async ({ request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
@@ -11,6 +12,15 @@ export const PUT: RequestHandler = async ({ request, ...event }) => {
|
||||
throw error(400, 'Email cannot be empty');
|
||||
}
|
||||
|
||||
// Capture old email before update to notify on change
|
||||
const existingUser = email !== undefined ? getUser(userId) : null;
|
||||
|
||||
updateUserProfile(userId, { displayName, email });
|
||||
|
||||
// Send notification to old email when email address changes
|
||||
if (isEmailConfigured() && existingUser?.email && email && existingUser.email !== email) {
|
||||
sendEmailChangedEmail(existingUser.email, existingUser.displayName, email).catch(() => {});
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { listNotes, createNote } from '$lib/server/notes-service.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
@@ -7,13 +8,13 @@ import type { NoteFilter } from '$lib/types/index.js';
|
||||
export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const filter = (url.searchParams.get('filter') || 'all') as NoteFilter;
|
||||
const result = listNotes(userId, filter);
|
||||
const result = listNotes(db, userId, filter);
|
||||
return json(result);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const body = await request.json();
|
||||
const newNote = createNote(userId, body);
|
||||
const newNote = createNote(db, userId, body);
|
||||
return json(newNote, { status: 201 });
|
||||
};
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
|
||||
import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js';
|
||||
import { fetchCollaboratorsForNotes } from '$lib/server/collaborators.js';
|
||||
import { isEmailConfigured, sendNotePermanentlyDeletedEmail } from '$lib/server/email.js';
|
||||
import { getPreferences } from '$lib/server/preferences.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const note = getNote(userId, params.id);
|
||||
const note = getNote(db, userId, params.id);
|
||||
if (!note) throw error(404, 'Note not found');
|
||||
|
||||
return json(note);
|
||||
@@ -14,7 +18,7 @@ export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const body = await request.json();
|
||||
const updated = updateNote(userId, params.id, body);
|
||||
const updated = updateNote(db, userId, params.id, body);
|
||||
if (!updated) throw error(404, 'Note not found');
|
||||
|
||||
return json(updated);
|
||||
@@ -22,10 +26,34 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteOwnership(params.id, userId);
|
||||
requireNoteOwnership(db, params.id, userId);
|
||||
|
||||
const deleted = deleteNote(userId, params.id);
|
||||
// Fetch note and collaborators before deletion to send notifications
|
||||
let collaboratorsToNotify: Array<{ email: string; displayName: string }> = [];
|
||||
let noteTitle = 'Untitled';
|
||||
|
||||
if (isEmailConfigured()) {
|
||||
const note = getNote(db, userId, params.id);
|
||||
if (note) {
|
||||
noteTitle = note.title || 'Untitled';
|
||||
const collabMap = fetchCollaboratorsForNotes(db, [params.id]);
|
||||
const collaborators = collabMap.get(params.id) ?? [];
|
||||
for (const collab of collaborators) {
|
||||
const prefs = getPreferences(db, collab.userId);
|
||||
if (prefs.notifyOnNoteDeleted !== 'false' && collab.email) {
|
||||
collaboratorsToNotify.push({ email: collab.email, displayName: collab.displayName });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleted = deleteNote(db, userId, params.id);
|
||||
if (!deleted) throw error(404, 'Note not found');
|
||||
|
||||
// Notify collaborators about the deleted note (fire-and-forget)
|
||||
for (const collab of collaboratorsToNotify) {
|
||||
sendNotePermanentlyDeletedEmail(collab.email, collab.displayName, noteTitle).catch(() => {});
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -2,16 +2,17 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment, updateAttachment } from '$lib/server/attachments.js';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(params.id, userId);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
|
||||
if (attachmentId) {
|
||||
const attachment = await getAttachment(attachmentId, params.id);
|
||||
const attachment = await getAttachment(db, attachmentId, params.id);
|
||||
if (!attachment) throw error(404, 'Attachment not found');
|
||||
|
||||
const wantThumb = url.searchParams.get('thumb') === '1';
|
||||
@@ -28,13 +29,13 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const list = await getAttachmentsByNote(params.id);
|
||||
const list = await getAttachmentsByNote(db, params.id);
|
||||
return json(list);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(params.id, userId);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
@@ -53,13 +54,13 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
}
|
||||
|
||||
const thumbnail = formData.get('thumbnail') as File | null;
|
||||
const attachment = await saveAttachment(params.id, file, userId, thumbnail);
|
||||
const attachment = await saveAttachment(db, params.id, file, userId, thumbnail);
|
||||
return json(attachment, { status: 201 });
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, url, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(params.id, userId);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||
@@ -67,7 +68,7 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
|
||||
const body = await request.json();
|
||||
if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required');
|
||||
|
||||
const updated = await updateAttachment(attachmentId, params.id, { featured: body.featured });
|
||||
const updated = await updateAttachment(db, attachmentId, params.id, { featured: body.featured });
|
||||
if (!updated) throw error(404, 'Attachment not found');
|
||||
|
||||
return json(updated);
|
||||
@@ -75,11 +76,11 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(params.id, userId);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||
|
||||
await deleteAttachment(attachmentId, params.id);
|
||||
await deleteAttachment(db, attachmentId, params.id);
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -5,18 +5,24 @@ import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { users, notes, noteCollaborators } from '$lib/server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import {
|
||||
isEmailConfigured,
|
||||
sendShareNotification,
|
||||
sendCollaboratorRemovedEmail
|
||||
} from '$lib/server/email.js';
|
||||
import { getPreferences } from '$lib/server/preferences.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(params.id, userId);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const collaborators = fetchCollaboratorsForNotes([params.id]);
|
||||
const collaborators = fetchCollaboratorsForNotes(db, [params.id]);
|
||||
return json(collaborators.get(params.id) ?? []);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteOwnership(params.id, userId);
|
||||
requireNoteOwnership(db, params.id, userId);
|
||||
|
||||
const body = await request.json();
|
||||
const targetUserId = body.userId;
|
||||
@@ -46,9 +52,42 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
|
||||
throw error(409, 'User is already a collaborator');
|
||||
}
|
||||
|
||||
addCollaborator(params.id, targetUserId, userId);
|
||||
addCollaborator(db, params.id, targetUserId, userId);
|
||||
|
||||
const collaborators = fetchCollaboratorsForNotes([params.id]);
|
||||
// Send email notification (fire-and-forget)
|
||||
if (isEmailConfigured()) {
|
||||
const targetPrefs = getPreferences(db, targetUserId);
|
||||
if (targetPrefs.notifyOnShare !== 'false') {
|
||||
const target = db
|
||||
.select({ email: users.email, displayName: users.displayName })
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.get();
|
||||
const sharer = db
|
||||
.select({ displayName: users.displayName })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.get();
|
||||
const noteData = db
|
||||
.select({ title: notes.title })
|
||||
.from(notes)
|
||||
.where(eq(notes.id, params.id))
|
||||
.get();
|
||||
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||
|
||||
if (target?.email) {
|
||||
sendShareNotification(
|
||||
target.email,
|
||||
target.displayName,
|
||||
sharer?.displayName || 'Someone',
|
||||
noteData?.title || 'Untitled',
|
||||
origin
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const collaborators = fetchCollaboratorsForNotes(db, [params.id]);
|
||||
return json(collaborators.get(params.id) ?? [], { status: 201 });
|
||||
};
|
||||
|
||||
@@ -60,14 +99,57 @@ export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
|
||||
throw error(400, 'userId query parameter is required');
|
||||
}
|
||||
|
||||
const { isOwner } = requireNoteAccess(params.id, userId);
|
||||
const { isOwner } = requireNoteAccess(db, params.id, userId);
|
||||
|
||||
// Owner can remove anyone. Collaborator can only remove self.
|
||||
if (!isOwner && targetUserId !== userId) {
|
||||
throw error(403, 'Only the owner can remove other collaborators');
|
||||
}
|
||||
|
||||
removeCollaborator(params.id, targetUserId);
|
||||
// Fetch collaborator info and note title before removal for email notification
|
||||
let removedUserEmail: string | undefined;
|
||||
let removedUserName: string | undefined;
|
||||
let noteTitle: string | undefined;
|
||||
let ownerName: string | undefined;
|
||||
|
||||
if (isEmailConfigured() && isOwner && targetUserId !== userId) {
|
||||
const targetPrefs = getPreferences(db, targetUserId);
|
||||
if (targetPrefs.notifyOnCollabRemoved !== 'false') {
|
||||
const target = db
|
||||
.select({ email: users.email, displayName: users.displayName })
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.get();
|
||||
const noteData = db
|
||||
.select({ title: notes.title })
|
||||
.from(notes)
|
||||
.where(eq(notes.id, params.id))
|
||||
.get();
|
||||
const owner = db
|
||||
.select({ displayName: users.displayName })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.get();
|
||||
removedUserEmail = target?.email ?? undefined;
|
||||
removedUserName = target?.displayName ?? undefined;
|
||||
noteTitle = noteData?.title ?? undefined;
|
||||
ownerName = owner?.displayName ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
removeCollaborator(db, params.id, targetUserId);
|
||||
|
||||
// Send notification to removed collaborator (fire-and-forget)
|
||||
if (removedUserEmail) {
|
||||
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||
sendCollaboratorRemovedEmail(
|
||||
removedUserEmail,
|
||||
removedUserName || '',
|
||||
ownerName || 'Someone',
|
||||
noteTitle || 'Untitled',
|
||||
origin
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
|
||||
import { listVersions } from '$lib/server/versions-service.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const versions = listVersions(db, params.id);
|
||||
return json(versions);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
|
||||
import { getVersion } from '$lib/server/versions-service.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const version = getVersion(db, params.id, params.versionId);
|
||||
if (!version) throw error(404, 'Version not found');
|
||||
|
||||
return json(version);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
|
||||
import { getVersion, snapshotCurrentNote } from '$lib/server/versions-service.js';
|
||||
import { updateNote } from '$lib/server/notes-service.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteAccess(db, params.id, userId);
|
||||
|
||||
const version = getVersion(db, params.id, params.versionId);
|
||||
if (!version) throw error(404, 'Version not found');
|
||||
|
||||
// Snapshot current state before overwriting so the user can undo the restore
|
||||
snapshotCurrentNote(db, params.id);
|
||||
|
||||
// Apply the restored content (updateNote will increment version + create a new snapshot)
|
||||
const restored = updateNote(db, userId, params.id, {
|
||||
title: version.title,
|
||||
content: version.content,
|
||||
checklistMode: version.checklistMode,
|
||||
color: version.color
|
||||
});
|
||||
|
||||
if (!restored) throw error(404, 'Note not found');
|
||||
|
||||
return json(restored);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { reorderNotes } from '$lib/server/notes-service.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
|
||||
@@ -12,6 +13,6 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
return json({ error: 'Invalid payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
reorderNotes(userId, orders);
|
||||
reorderNotes(db, userId, orders);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { getPreferences, upsertPreferences } from '$lib/server/preferences.js';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const userId = getUserId(event);
|
||||
const prefs = getPreferences(db, userId);
|
||||
return json(prefs);
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async ({ request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const body: Record<string, string> = await request.json();
|
||||
const updated = upsertPreferences(db, userId, body);
|
||||
return json(updated);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { searchNotes } from '$lib/server/notes-service.js';
|
||||
|
||||
@@ -11,6 +12,6 @@ export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
return json([]);
|
||||
}
|
||||
|
||||
const results = searchNotes(userId, query);
|
||||
const results = searchNotes(db, userId, query);
|
||||
return json(results);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
|
||||
import { fetchTagsForNotes } from '$lib/server/tags.js';
|
||||
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
@@ -13,18 +14,18 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
return json({ error: 'Invalid changes' }, { status: 400 });
|
||||
}
|
||||
|
||||
await processSyncPush(changes, userId);
|
||||
await processSyncPush(db, changes, userId);
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async ({ url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const since = parseInt(url.searchParams.get('since') || '0', 10);
|
||||
const changes = await getChangesSince(since, userId);
|
||||
const changes = await getChangesSince(db, since, userId);
|
||||
|
||||
const noteIds = changes.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
const tagMap = fetchTagsForNotes(db, noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(db, noteIds);
|
||||
const changesWithTags = changes.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
|
||||
@@ -72,7 +72,7 @@ test.describe('Image Attachments', () => {
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// When the user features the image by clicking the star
|
||||
// When the user features the image
|
||||
await page.getByTestId('attachment-thumbnail').hover();
|
||||
await page.getByTestId('toggle-featured').click();
|
||||
|
||||
@@ -91,8 +91,6 @@ test.describe('Image Attachments', () => {
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// Feature the image
|
||||
await page.getByTestId('attachment-thumbnail').hover();
|
||||
await page.getByTestId('toggle-featured').click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
@@ -155,7 +153,7 @@ test.describe('Image Attachments', () => {
|
||||
});
|
||||
|
||||
test('Scenario: Image toggle on new note auto-saves and opens upload panel', async ({ authenticatedPage: page }) => {
|
||||
// When the user opens a new note and clicks the image toggle
|
||||
// When the user opens a new note and enables the image panel
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Auto-Save Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
@@ -163,10 +161,8 @@ test.describe('Image Attachments', () => {
|
||||
// Then the upload panel is visible (note was auto-saved)
|
||||
await expect(page.getByTestId('image-upload')).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
// And the note persists in the list after closing
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// And the note exists in the list
|
||||
await expect(noteCard(page, 'Auto-Save Test')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,10 +70,10 @@ test.describe.serial('Authentication', () => {
|
||||
});
|
||||
|
||||
test.describe('Multi-user', () => {
|
||||
test('admin can create a new user via API', async ({ authenticatedPage: page }) => {
|
||||
// authenticatedPage fixture already handles setup/login
|
||||
test('Scenario: Admin-created user is registered successfully', async ({ authenticatedPage: page }) => {
|
||||
// Given the admin is authenticated
|
||||
|
||||
// Create a new user via admin API
|
||||
// When the admin creates a new user
|
||||
const response = await page.request.post('/api/admin/users', {
|
||||
data: {
|
||||
email: 'user2@test.com',
|
||||
@@ -83,6 +83,7 @@ test.describe('Multi-user', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Then the user is created successfully
|
||||
expect(response.status()).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
+37
-33
@@ -1,7 +1,23 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/** Create a checklist note via UI. Leaves the editor closed. */
|
||||
async function createChecklistNote(page: Page, title: string, items: string[]) {
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill(title);
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await page.getByTestId('checklist-input').nth(i).fill(items[i]);
|
||||
if (i < items.length - 1) {
|
||||
await page.getByTestId('checklist-input').nth(i).press('Enter');
|
||||
}
|
||||
}
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, title)).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Checklist', () => {
|
||||
test('Scenario: Checklist UI replaces editor when checklist mode is enabled', async ({ authenticatedPage: page }) => {
|
||||
test('Scenario: Checklist replaces the rich text editor when enabled', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is creating a new note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
@@ -15,16 +31,9 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('tiptap-editor')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Checklist item can be added and persisted', async ({ authenticatedPage: page }) => {
|
||||
// When the user creates a checklist note with an item "Buy milk"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Shopping List');
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Buy milk');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note appears in the list
|
||||
await expect(page.getByText('Shopping List')).toBeVisible();
|
||||
test('Scenario: Checklist item persists after closing and reopening the note', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with an item "Buy milk" exists
|
||||
await createChecklistNote(page, 'Shopping List', ['Buy milk']);
|
||||
|
||||
// When the user reopens the note
|
||||
await noteCard(page, 'Shopping List').click();
|
||||
@@ -34,13 +43,9 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
|
||||
});
|
||||
|
||||
test('Scenario: Checking an item marks it as completed', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with an item "Buy milk"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Tasks');
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Buy milk');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
test('Scenario: Checked item moves to the done section', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with an item "Buy milk" exists
|
||||
await createChecklistNote(page, 'Tasks', ['Buy milk']);
|
||||
|
||||
// When the user reopens the note and checks the item
|
||||
await noteCard(page, 'Tasks').click();
|
||||
@@ -50,13 +55,13 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// And reopening the note shows the item in the done section (expanded by default)
|
||||
// And the done state persists after reopening
|
||||
await noteCard(page, 'Tasks').click();
|
||||
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
|
||||
});
|
||||
|
||||
test('Scenario: New checklist item is added by pressing Enter', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is editing a checklist note
|
||||
test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with one item
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('First item');
|
||||
@@ -68,46 +73,45 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('Scenario: Empty checklist item is removed by pressing Backspace', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with two items
|
||||
test('Scenario: Backspace on empty item removes it from the list', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist with two items where the second is empty
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('First item');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
|
||||
|
||||
// When the user presses Backspace on the empty second item
|
||||
// When the user presses Backspace on the empty item
|
||||
await page.getByTestId('checklist-input').nth(1).press('Backspace');
|
||||
|
||||
// Then only the first item remains
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Scenario: Completed checklist items are separated and can be expanded', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with a completed item and an uncompleted item
|
||||
test('Scenario: Completed items are separated into a done section', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with two items
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Hide Done Test');
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Done task');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
|
||||
|
||||
// When the user completes the first item
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
|
||||
// Then the completed item is separated from the active list
|
||||
// Then only the pending item remains in the active list
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
|
||||
|
||||
// And the "done" toggle shows the count
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toBeVisible();
|
||||
// And the done section shows the completed item
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
|
||||
// Then the completed items are shown in the done section (expanded by default)
|
||||
await expect(page.getByTestId('checklist-done-section')).toBeVisible();
|
||||
await expect(page.getByTestId('checklist-done-section')).toContainText('Done task');
|
||||
});
|
||||
|
||||
test('Scenario: Switching back from checklist mode restores the text area', async ({ authenticatedPage: page }) => {
|
||||
// Given the user has checklist mode enabled
|
||||
test('Scenario: Disabling checklist mode restores the rich text editor', async ({ authenticatedPage: page }) => {
|
||||
// Given checklist mode is enabled on a new note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await expect(page.getByTestId('checklist')).toBeVisible();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/** Run a TipTap command chain via the exposed editor instance on the DOM element */
|
||||
async function runTiptapCommand(page: Page, commandFn: string) {
|
||||
@@ -38,54 +38,6 @@ test.describe('Rich text formatting', () => {
|
||||
await expect(page.getByTestId('note-content-input')).toHaveValue('hello **world**');
|
||||
});
|
||||
|
||||
test('Scenario: Italic shortcut formats selected text in the editor', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is editing a new note with content "hello world"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await typeViaMarkdown(page, 'hello world');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
|
||||
// When the user selects "world" and applies italic formatting
|
||||
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).toggleItalic().run()');
|
||||
|
||||
// Then the selected text appears italic in the editor
|
||||
await expect(editor.locator('em')).toHaveText('world');
|
||||
});
|
||||
|
||||
test('Scenario: Formatting toolbar applies bold to selected text', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is editing a note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await typeViaMarkdown(page, 'hello world');
|
||||
|
||||
// When the user selects "world" and clicks the bold button
|
||||
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).run()');
|
||||
await page.getByTestId('format-bold').click();
|
||||
|
||||
// Then the selected text appears bold
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await expect(editor.locator('strong')).toHaveText('world');
|
||||
});
|
||||
|
||||
test('Scenario: Formatting toolbar is always visible when editing', async ({ authenticatedPage: page }) => {
|
||||
// When the user opens a new note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
// Then the formatting toolbar is visible immediately
|
||||
await expect(page.getByTestId('formatting-toolbar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Inline code shortcut wraps selected text', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is editing a note with content "use npm install"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await typeViaMarkdown(page, 'use npm install');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
|
||||
// When the user selects "npm install" and applies code formatting
|
||||
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:5,to:16}).toggleCode().run()');
|
||||
|
||||
// Then the selected text appears as inline code
|
||||
await expect(editor.locator('code')).toHaveText('npm install');
|
||||
});
|
||||
|
||||
test('Scenario: Existing note shows rich text in TipTap editor', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with bold markdown content exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
const COVERAGE_DIR = join(process.cwd(), 'coverage', 'e2e', 'raw');
|
||||
|
||||
let counter = 0;
|
||||
|
||||
/**
|
||||
* Collect Istanbul coverage data from the browser page.
|
||||
* The vite-plugin-istanbul injects `window.__coverage__` when COVERAGE=true.
|
||||
*/
|
||||
export async function collectCoverage(page: Page, testTitle: string) {
|
||||
if (process.env.VITE_COVERAGE !== 'true') return;
|
||||
|
||||
const coverage = await page.evaluate(() => (window as any).__coverage__);
|
||||
if (!coverage) return;
|
||||
|
||||
mkdirSync(COVERAGE_DIR, { recursive: true });
|
||||
|
||||
const safeName = testTitle.replace(/[^a-zA-Z0-9-_]/g, '_');
|
||||
const filename = `coverage-${safeName}-${counter++}-${Date.now()}.json`;
|
||||
writeFileSync(join(COVERAGE_DIR, filename), JSON.stringify(coverage));
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { test as base, expect, type Page, type Locator } from '@playwright/test';
|
||||
import { collectCoverage } from './coverage';
|
||||
|
||||
const TEST_EMAIL = 'admin@test.com';
|
||||
const TEST_PASSWORD = 'testpassword123';
|
||||
const TEST_DISPLAY_NAME = 'Test Admin';
|
||||
|
||||
const COLLAB_EMAIL = 'collab@test.com';
|
||||
const COLLAB_PASSWORD = 'collabpass123';
|
||||
const COLLAB_NAME = 'Collaborator User';
|
||||
|
||||
/** Find a specific note card by its title text */
|
||||
export function noteCard(page: Page, title: string): Locator {
|
||||
return page.locator('[data-testid="note-card"]', { hasText: title });
|
||||
@@ -12,11 +17,39 @@ export function noteCard(page: Page, title: string): Locator {
|
||||
/**
|
||||
* Extended test fixture that handles setup/auth.
|
||||
*/
|
||||
export const test = base.extend<{ authenticatedPage: Page }>({
|
||||
authenticatedPage: async ({ page }, use) => {
|
||||
export const test = base.extend<{ authenticatedPage: Page; collabPage: Page }>({
|
||||
authenticatedPage: async ({ page }, use, testInfo) => {
|
||||
// Setup: create user and login
|
||||
await setupAndLogin(page);
|
||||
await use(page);
|
||||
// Teardown: collect coverage data from the browser
|
||||
await collectCoverage(page, testInfo.title);
|
||||
},
|
||||
collabPage: async ({ browser, authenticatedPage }, use, testInfo) => {
|
||||
// Create collab user via admin API (idempotent)
|
||||
const res = await authenticatedPage.request.post('/api/admin/users', {
|
||||
data: {
|
||||
email: COLLAB_EMAIL,
|
||||
displayName: COLLAB_NAME,
|
||||
password: COLLAB_PASSWORD,
|
||||
role: 'user'
|
||||
}
|
||||
});
|
||||
// Ignore 409 — user already exists
|
||||
|
||||
// Login as collaborator in a new browser context
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('email-input').fill(COLLAB_EMAIL);
|
||||
await page.getByTestId('password-input').fill(COLLAB_PASSWORD);
|
||||
await page.getByTestId('login-btn').click();
|
||||
await page.waitForURL('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await use(page);
|
||||
// Teardown: collect coverage data from the browser
|
||||
await collectCoverage(page, testInfo.title + '-collab');
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -57,4 +90,17 @@ export async function setupAndLogin(page: Page) {
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
export { expect, TEST_EMAIL, TEST_PASSWORD };
|
||||
/** Create a note via the UI and close the editor. Use in Given steps for state setup. */
|
||||
export async function createNote(page: Page, title: string, content?: string) {
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill(title);
|
||||
if (content) {
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially(content);
|
||||
}
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, title)).toBeVisible();
|
||||
}
|
||||
|
||||
export { expect, TEST_EMAIL, TEST_PASSWORD, COLLAB_EMAIL, COLLAB_PASSWORD, COLLAB_NAME };
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Notes CRUD', () => {
|
||||
test('Scenario: New note appears in the notes list after creation', async ({ authenticatedPage: page }) => {
|
||||
// When the user creates a note titled "My First Note" with content "Hello world!"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('My First Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Hello world!');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'My First Note', 'Hello world!');
|
||||
|
||||
// Then the note is visible in the notes list
|
||||
await expect(noteCard(page, 'My First Note')).toBeVisible();
|
||||
@@ -16,12 +11,7 @@ test.describe('Notes CRUD', () => {
|
||||
|
||||
test('Scenario: Edited note title is reflected in the notes list', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Original Title" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Original Title');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Original content');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Original Title', 'Original content');
|
||||
|
||||
// When the user changes the title to "Updated Title"
|
||||
await noteCard(page, 'Original Title').click();
|
||||
@@ -35,9 +25,7 @@ test.describe('Notes CRUD', () => {
|
||||
|
||||
test('Scenario: Trashed note disappears from the main view', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Delete Me" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Delete Me');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Delete Me');
|
||||
|
||||
// When the user trashes the note
|
||||
const card = noteCard(page, 'Delete Me');
|
||||
@@ -47,23 +35,4 @@ test.describe('Notes CRUD', () => {
|
||||
// Then the note is no longer visible
|
||||
await expect(page.getByText('Delete Me')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Bold formatting applies to selected text in the editor', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is editing a note with content "hello world"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
await page.getByTestId('note-content-input').fill('hello world');
|
||||
await page.getByTestId('markdown-toggle').click();
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
|
||||
// When the user selects "world" and applies bold formatting
|
||||
await page.getByTestId('tiptap-editor').evaluate((el) => {
|
||||
const ed = (el as any).__tiptapEditor;
|
||||
if (!ed) return;
|
||||
ed.chain().focus().setTextSelection({ from: 7, to: 12 }).toggleBold().run();
|
||||
});
|
||||
|
||||
// Then the selected text appears bold in the editor
|
||||
await expect(editor.locator('strong')).toHaveText('world');
|
||||
});
|
||||
});
|
||||
|
||||
+18
-32
@@ -1,7 +1,7 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Offline Support', () => {
|
||||
test('Scenario: Sync status is visible to the user', async ({ authenticatedPage: page }) => {
|
||||
test('Scenario: Sync status indicator is visible on the main page', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is authenticated
|
||||
// Then the sync status indicator is displayed
|
||||
await expect(page.getByTestId('sync-indicator')).toBeVisible();
|
||||
@@ -9,9 +9,7 @@ test.describe('Offline Support', () => {
|
||||
|
||||
test('Scenario: Created note is immediately visible in the notes list', async ({ authenticatedPage: page }) => {
|
||||
// When the user creates a note titled "Offline Test Note"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Offline Test Note');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Offline Test Note');
|
||||
|
||||
// Then the note appears in the list
|
||||
await expect(page.getByText('Offline Test Note')).toBeVisible();
|
||||
@@ -19,10 +17,7 @@ test.describe('Offline Support', () => {
|
||||
|
||||
test('Scenario: Edited note persists in the UI when offline', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Offline Edit" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Offline Edit');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(page.getByText('Offline Edit')).toBeVisible();
|
||||
await createNote(page, 'Offline Edit');
|
||||
|
||||
// When the network is unavailable
|
||||
await page.context().setOffline(true);
|
||||
@@ -33,10 +28,10 @@ test.describe('Offline Support', () => {
|
||||
await page.getByTestId('note-title-input').fill('Offline Edited');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note "Offline Edited" appears in the list
|
||||
// Then the updated note appears in the list
|
||||
await expect(page.getByText('Offline Edited')).toBeVisible();
|
||||
|
||||
// And a "Saved offline" toast is shown
|
||||
// And an offline save confirmation is shown
|
||||
await expect(page.getByText('Saved offline')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -49,28 +44,27 @@ test.describe('Offline Support', () => {
|
||||
await page.getByTestId('note-title-input').fill('Offline New');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note "Offline New" appears in the list
|
||||
// Then the note appears in the list
|
||||
await expect(page.getByText('Offline New')).toBeVisible();
|
||||
|
||||
// And a "Saved offline" toast is shown
|
||||
// And an offline save confirmation is shown
|
||||
await expect(page.getByText('Saved offline')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Offline note survives a page reload after reconnecting', async ({ authenticatedPage: page }) => {
|
||||
// When the network is unavailable
|
||||
// Given the network is unavailable
|
||||
await page.context().setOffline(true);
|
||||
|
||||
// And the user creates a note titled "Survive Reload"
|
||||
// And a note titled "Survive Reload" is created offline
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Survive Reload');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(page.getByText('Survive Reload')).toBeVisible();
|
||||
|
||||
// When the network is restored (triggers 'online' event + navigator.onLine = true)
|
||||
// When the network is restored and sync completes
|
||||
const syncResponse = page.waitForResponse('**/api/sync');
|
||||
await page.context().setOffline(false);
|
||||
|
||||
// And the sync completes
|
||||
await page.waitForResponse('**/api/sync');
|
||||
await syncResponse;
|
||||
|
||||
// And the user reloads the page
|
||||
await page.reload();
|
||||
@@ -81,27 +75,19 @@ test.describe('Offline Support', () => {
|
||||
|
||||
test('Scenario: Notes load from local cache when the API is unavailable', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Cached Note" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Cached Note');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(page.getByText('Cached Note')).toBeVisible();
|
||||
await createNote(page, 'Cached Note');
|
||||
|
||||
// When the API is unavailable (simulating service worker serving cached shell)
|
||||
// When the API becomes unavailable and the page is reloaded
|
||||
await page.route('**/api/**', (route) => route.abort());
|
||||
|
||||
// And the user reloads the page
|
||||
await page.reload();
|
||||
|
||||
// Then the note is still visible (loaded from IDB cache)
|
||||
// Then the note is still visible from the local cache
|
||||
await expect(page.getByText('Cached Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Trashed note disappears from the list when offline', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Offline Trash" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Offline Trash');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(page.getByText('Offline Trash')).toBeVisible();
|
||||
await createNote(page, 'Offline Trash');
|
||||
|
||||
// When the network is unavailable
|
||||
await page.context().setOffline(true);
|
||||
@@ -114,7 +100,7 @@ test.describe('Offline Support', () => {
|
||||
// Then the note is no longer visible
|
||||
await expect(page.getByText('Offline Trash')).not.toBeVisible();
|
||||
|
||||
// And a "Saved offline" toast is shown
|
||||
// And an offline save confirmation is shown
|
||||
await expect(page.getByText('Saved offline')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Organization Features', () => {
|
||||
test('Scenario: Pinned note appears under the Pinned section', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Pin Me" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Pin Me');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Pin Me');
|
||||
|
||||
// When the user pins the note
|
||||
const pinCard = noteCard(page, 'Pin Me');
|
||||
@@ -18,9 +16,7 @@ test.describe('Organization Features', () => {
|
||||
|
||||
test('Scenario: Archived note is removed from the main view', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Archive Me" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Archive Me');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Archive Me');
|
||||
|
||||
// When the user archives the note
|
||||
const archiveCard = noteCard(page, 'Archive Me');
|
||||
@@ -31,101 +27,46 @@ test.describe('Organization Features', () => {
|
||||
await expect(page.getByText('Archive Me')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Note color is changed via the color picker', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is creating a note titled "Colored Note"
|
||||
test('Scenario: Note card reflects the selected color', async ({ authenticatedPage: page }) => {
|
||||
// Given the user is creating a new note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Colored Note');
|
||||
|
||||
// When the user sets the note color to "Coral"
|
||||
// When the user selects the "Coral" color
|
||||
await page.getByTestId('color-picker-toggle').click();
|
||||
await page.getByTestId('color-coral').click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note is saved and visible in the notes list
|
||||
// Then the note is visible in the notes list
|
||||
await expect(noteCard(page, 'Colored Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Filtering by tag shows only matching notes', async ({ authenticatedPage: page }) => {
|
||||
// Given a note tagged #important and an untagged note exist
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Tagged Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('This is #important');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Untagged Note');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Tagged Note', 'This is #important');
|
||||
await createNote(page, 'Untagged Note');
|
||||
|
||||
// When the user filters by the #important tag
|
||||
const tagChip = page.getByTestId('tag-filter').getByTestId('tag-chip').filter({ hasText: '#important' });
|
||||
if (await tagChip.isVisible()) {
|
||||
await tagChip.click();
|
||||
await expect(tagChip).toBeVisible();
|
||||
await tagChip.click();
|
||||
|
||||
// Then only the tagged note is visible
|
||||
await expect(page.getByText('Tagged Note').first()).toBeVisible();
|
||||
}
|
||||
// Then only the tagged note is visible
|
||||
await expect(page.getByText('Tagged Note').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Note Sorting', () => {
|
||||
test('Scenario: Notes can be sorted by last updated', async ({ authenticatedPage: page }) => {
|
||||
// Given two notes exist with different update times
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Sort-First');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Sort-First')).toBeVisible();
|
||||
test('Scenario: Most recently updated note appears first', async ({ authenticatedPage: page }) => {
|
||||
// Given two notes exist created at different times
|
||||
await createNote(page, 'Sort-First');
|
||||
await createNote(page, 'Sort-Second');
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Sort-Second');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Sort-Second')).toBeVisible();
|
||||
|
||||
// When the user selects "Updated" sort mode
|
||||
await page.getByTestId('sort-updated').click();
|
||||
|
||||
// Then Sort-Second (most recently updated) appears before Sort-First
|
||||
// Then the most recently created note appears before the older one
|
||||
const sortFirst = noteCard(page, 'Sort-First');
|
||||
const sortSecond = noteCard(page, 'Sort-Second');
|
||||
const firstBox = await sortSecond.boundingBox();
|
||||
const secondBox = await sortFirst.boundingBox();
|
||||
expect(firstBox!.y).toBeLessThanOrEqual(secondBox!.y);
|
||||
});
|
||||
|
||||
test('Scenario: Notes can be sorted by creation date', async ({ authenticatedPage: page }) => {
|
||||
// Given two notes exist
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Created-Older');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Created-Older')).toBeVisible();
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Created-Newer');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Created-Newer')).toBeVisible();
|
||||
|
||||
// When the user selects "Created" sort mode
|
||||
await page.getByTestId('sort-created').click();
|
||||
|
||||
// Then Created-Newer appears before Created-Older
|
||||
const newer = noteCard(page, 'Created-Newer');
|
||||
const older = noteCard(page, 'Created-Older');
|
||||
const newerBox = await newer.boundingBox();
|
||||
const olderBox = await older.boundingBox();
|
||||
expect(newerBox!.y).toBeLessThanOrEqual(olderBox!.y);
|
||||
});
|
||||
|
||||
test('Scenario: Sort preference persists across page reload', async ({ authenticatedPage: page }) => {
|
||||
// Given the user selects "Created" sort mode
|
||||
await page.getByTestId('sort-created').click();
|
||||
|
||||
// When the page is reloaded
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the "Created" sort mode is still active
|
||||
const createdBtn = page.getByTestId('sort-created');
|
||||
await expect(createdBtn).toHaveClass(/bg-\[var\(--primary\)\]/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import { test, expect, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Search', () => {
|
||||
test('Scenario: Searching by title returns the matching note', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Grocery Errands" and a note titled "Work Meeting" exist
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Grocery Errands');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Work Meeting');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Grocery Errands');
|
||||
await createNote(page, 'Work Meeting');
|
||||
|
||||
// When the user searches for "Grocery"
|
||||
await page.getByTestId('search-input').fill('Grocery');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Then "Grocery Errands" is visible in the results
|
||||
await expect(page.getByText('Grocery Errands')).toBeVisible();
|
||||
@@ -21,16 +15,10 @@ test.describe('Search', () => {
|
||||
|
||||
test('Scenario: Searching by content returns the matching note', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Recipe" with content "Pasta with tomato sauce" exists
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Recipe');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Pasta with tomato sauce');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await createNote(page, 'Recipe', 'Pasta with tomato sauce');
|
||||
|
||||
// When the user searches for "tomato"
|
||||
await page.getByTestId('search-input').fill('tomato');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Then "Recipe" is visible in the results
|
||||
await expect(page.getByText('Recipe')).toBeVisible();
|
||||
@@ -39,7 +27,6 @@ test.describe('Search', () => {
|
||||
test('Scenario: Searching for a nonexistent term yields no results', async ({ authenticatedPage: page }) => {
|
||||
// When the user searches for a term that matches nothing
|
||||
await page.getByTestId('search-input').fill('xyznonexistent');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Then no notes are displayed
|
||||
await expect(page.getByTestId('note-card')).toHaveCount(0);
|
||||
|
||||
+149
-15
@@ -1,12 +1,143 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
const DEFAULT_PREFS = {
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
sidebarDefaultState: 'open'
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset preferences to defaults on both server and client localStorage.
|
||||
*
|
||||
* Server reset ensures GET /api/preferences returns defaults.
|
||||
* localStorage reset ensures initPreferences() picks up correct values
|
||||
* even if the sync request is blocked or returns stale data.
|
||||
*/
|
||||
async function resetPreferences(page: Page) {
|
||||
await page.request.put('/api/preferences', {
|
||||
data: {
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: 'false',
|
||||
sidebarDefaultState: 'open'
|
||||
}
|
||||
});
|
||||
await page.evaluate(
|
||||
(prefs) => localStorage.setItem('crumbs-preferences', JSON.stringify(prefs)),
|
||||
DEFAULT_PREFS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a URL with reliable preference state.
|
||||
*
|
||||
* page.goto() triggers a full page reload which re-runs initPreferences():
|
||||
* 1. loadFromLocalStorage() — synchronous, returns correct value
|
||||
* 2. syncPreferencesFromServer() — async, can return stale data from
|
||||
* parallel test workers that share the same user's server-side prefs
|
||||
*
|
||||
* This helper blocks the one GET /api/preferences sync request so
|
||||
* initPreferences() falls back to localStorage (which is always correct
|
||||
* from either resetPreferences or updatePreference on the settings page).
|
||||
*/
|
||||
async function gotoWithStablePrefs(page: Page, url: string) {
|
||||
await page.route(
|
||||
'**/api/preferences',
|
||||
(route) => route.abort(),
|
||||
{ times: 1 }
|
||||
);
|
||||
await page.goto(url);
|
||||
}
|
||||
|
||||
test.describe('Settings — Preferences', () => {
|
||||
test('Scenario: Preferences tab is visible in settings nav', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// When the user navigates to settings
|
||||
await page.goto('/settings');
|
||||
|
||||
// Then the Preferences nav link is visible
|
||||
await expect(page.getByTestId('settings-nav-preferences')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Default note mode change applies to new notes', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
await resetPreferences(page);
|
||||
|
||||
// Given the user sets default note mode to Markdown
|
||||
await gotoWithStablePrefs(page, '/settings/preferences');
|
||||
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
|
||||
await page.getByTestId('pref-mode-markdown').click();
|
||||
await putResponse;
|
||||
|
||||
// When the user creates a new note
|
||||
await gotoWithStablePrefs(page, '/');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
// Then the note editor opens in markdown mode (textarea visible)
|
||||
await expect(page.getByTestId('note-content-input')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Footer toggle hides and shows footer', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
await resetPreferences(page);
|
||||
|
||||
// Given the footer is visible
|
||||
await gotoWithStablePrefs(page, '/');
|
||||
await expect(page.getByTestId('app-footer')).toBeVisible();
|
||||
|
||||
// When the user enables the hide footer preference
|
||||
await gotoWithStablePrefs(page, '/settings/preferences');
|
||||
await page.getByTestId('pref-hide-footer').check();
|
||||
|
||||
// Then the footer is hidden
|
||||
await gotoWithStablePrefs(page, '/');
|
||||
await expect(page.getByTestId('app-footer')).not.toBeVisible();
|
||||
|
||||
// When the user disables the hide footer preference
|
||||
await gotoWithStablePrefs(page, '/settings/preferences');
|
||||
await page.getByTestId('pref-hide-footer').uncheck();
|
||||
|
||||
// Then the footer is visible again
|
||||
await gotoWithStablePrefs(page, '/');
|
||||
await expect(page.getByTestId('app-footer')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Preferences persist across page reload', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
await resetPreferences(page);
|
||||
|
||||
// Given the user changes default note mode to Markdown
|
||||
await gotoWithStablePrefs(page, '/settings/preferences');
|
||||
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
|
||||
await page.getByTestId('pref-mode-markdown').click();
|
||||
await putResponse;
|
||||
|
||||
// When the page is reloaded (block sync to prevent parallel-test interference)
|
||||
await page.route('**/api/preferences', (route) => route.abort(), { times: 1 });
|
||||
await page.reload();
|
||||
|
||||
// Then the Markdown button is still selected (has primary styling)
|
||||
const mdBtn = page.getByTestId('pref-mode-markdown');
|
||||
await expect(mdBtn).toHaveClass(/font-medium/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Settings — API Key Management', () => {
|
||||
test('Scenario: Created API key appears in the keys list', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Use a unique name to avoid strict-mode violations from parallel test runs
|
||||
const keyName = `Test Key ${Date.now()}`;
|
||||
|
||||
// When the user navigates to the MCP settings and creates an API key
|
||||
await page.goto('/settings/mcp');
|
||||
await page.getByTestId('api-key-name-input').fill('Settings Test Key');
|
||||
await page.getByTestId('api-key-name-input').fill(keyName);
|
||||
await page.getByTestId('create-api-key-btn').click();
|
||||
|
||||
// Then the key is shown once for copying
|
||||
@@ -15,20 +146,22 @@ test.describe('Settings — API Key Management', () => {
|
||||
expect(keyValue).toMatch(/^crumbs_/);
|
||||
|
||||
// And it appears in the keys list
|
||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Settings Test Key' });
|
||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: keyName });
|
||||
await expect(keyItem).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Revoked API key disappears from the keys list', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
const keyName = `Revoke Key ${Date.now()}`;
|
||||
|
||||
// Given an API key exists
|
||||
await page.goto('/settings/mcp');
|
||||
await page.getByTestId('api-key-name-input').fill('Revoke Me');
|
||||
await page.getByTestId('api-key-name-input').fill(keyName);
|
||||
await page.getByTestId('create-api-key-btn').click();
|
||||
await expect(page.getByTestId('created-key-display')).toBeVisible();
|
||||
|
||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Revoke Me' });
|
||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: keyName });
|
||||
await expect(keyItem).toBeVisible();
|
||||
|
||||
// When the user revokes the key
|
||||
@@ -39,33 +172,34 @@ test.describe('Settings — API Key Management', () => {
|
||||
await expect(keyItem).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Settings page is accessible from sidebar', async ({
|
||||
test('Scenario: Sidebar settings link opens the preferences page', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the user is on the main page
|
||||
// When the user clicks the Settings link in the sidebar
|
||||
// When the user navigates to settings from the sidebar
|
||||
await page.getByTestId('settings-link').click();
|
||||
|
||||
// Then the settings page is shown
|
||||
await expect(page).toHaveURL('/settings');
|
||||
// Then the preferences page is displayed
|
||||
await expect(page).toHaveURL('/settings/preferences');
|
||||
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Settings subpages are navigable', async ({
|
||||
test('Scenario: Settings tabs navigate between subpages', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the user is on the settings page
|
||||
await page.goto('/settings');
|
||||
|
||||
// When the user clicks the MCP Server nav link
|
||||
await page.getByRole('link', { name: 'MCP Server' }).click();
|
||||
// When the user selects the API tab
|
||||
await page.getByRole('link', { name: 'API' }).click();
|
||||
|
||||
// Then the MCP settings page is shown
|
||||
// Then the API settings are displayed
|
||||
await expect(page).toHaveURL('/settings/mcp');
|
||||
await expect(page.getByRole('heading', { name: 'API Keys' })).toBeVisible();
|
||||
|
||||
// When the user clicks back to Profile
|
||||
// When the user selects the Profile tab
|
||||
await page.getByRole('link', { name: 'Profile' }).click();
|
||||
await expect(page).toHaveURL('/settings');
|
||||
|
||||
// Then the profile settings are displayed
|
||||
await expect(page).toHaveURL('/settings/profile');
|
||||
});
|
||||
});
|
||||
|
||||
+114
-191
@@ -1,233 +1,156 @@
|
||||
import { test, expect, noteCard, TEST_EMAIL, TEST_PASSWORD } from './helpers/fixtures.js';
|
||||
import type { Page, BrowserContext } from '@playwright/test';
|
||||
|
||||
const COLLAB_EMAIL = 'collab@test.com';
|
||||
const COLLAB_PASSWORD = 'collabpass123';
|
||||
const COLLAB_NAME = 'Collaborator User';
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Create a second user via the admin API (using the admin's session cookies).
|
||||
* Create a note via API and share it with the collaborator.
|
||||
* Returns the note object (with id).
|
||||
*/
|
||||
async function createCollaboratorUser(adminPage: Page) {
|
||||
const cookies = await adminPage.context().cookies();
|
||||
const sessionCookie = cookies.find((c) => c.name === 'session');
|
||||
if (!sessionCookie) throw new Error('No session cookie found');
|
||||
|
||||
const res = await adminPage.request.post('/api/admin/users', {
|
||||
data: {
|
||||
email: COLLAB_EMAIL,
|
||||
displayName: COLLAB_NAME,
|
||||
password: COLLAB_PASSWORD,
|
||||
role: 'user'
|
||||
}
|
||||
async function createSharedNote(
|
||||
ownerPage: Page,
|
||||
collabPage: Page,
|
||||
title: string,
|
||||
content = 'Shared content'
|
||||
) {
|
||||
// Create note
|
||||
const res = await ownerPage.request.post('/api/notes', {
|
||||
data: { title, content }
|
||||
});
|
||||
if (!res.ok()) {
|
||||
const body = await res.text();
|
||||
// User may already exist from prior test
|
||||
if (!body.includes('already exists') && res.status() !== 409) {
|
||||
throw new Error(`Failed to create collaborator user: ${res.status()} ${body}`);
|
||||
}
|
||||
}
|
||||
const note = await res.json();
|
||||
|
||||
// Find the collab user ID
|
||||
const usersRes = await ownerPage.request.get('/api/users/search?q=collab');
|
||||
const users = await usersRes.json();
|
||||
const collabUser = users[0];
|
||||
|
||||
// Share the note
|
||||
await ownerPage.request.post(`/api/notes/${note.id}/collaborators`, {
|
||||
data: { userId: collabUser.id }
|
||||
});
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in as the collaborator user in a new browser context.
|
||||
*/
|
||||
async function loginAsCollaborator(browser: import('@playwright/test').Browser): Promise<{ context: BrowserContext; page: Page }> {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('email-input').fill(COLLAB_EMAIL);
|
||||
await page.getByTestId('password-input').fill(COLLAB_PASSWORD);
|
||||
await page.getByTestId('login-btn').click();
|
||||
await page.waitForURL('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
return { context, page };
|
||||
}
|
||||
test.describe('Note Sharing', () => {
|
||||
test('Scenario: Owner shares a note via UI and collaborator sees it', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// Given a note titled "Share Test" exists
|
||||
await page.request.post('/api/notes', {
|
||||
data: { title: 'Share Test', content: 'Shared content' }
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(noteCard(page, 'Share Test')).toBeVisible();
|
||||
|
||||
test.describe.serial('Note Sharing', () => {
|
||||
let collabContext: BrowserContext;
|
||||
let collabPage: Page;
|
||||
|
||||
test('Scenario: Owner shares a note with another user', async ({ authenticatedPage: page, browser }) => {
|
||||
// Given a second user exists
|
||||
await createCollaboratorUser(page);
|
||||
|
||||
// When the owner creates a note titled "Shared Note"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Shared Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Shared content');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Shared Note')).toBeVisible();
|
||||
|
||||
// And the owner opens the share dialog
|
||||
await noteCard(page, 'Shared Note').click();
|
||||
// And the owner opens the share dialog and adds the collaborator
|
||||
await noteCard(page, 'Share Test').click();
|
||||
await page.getByTestId('share-toggle').click();
|
||||
await expect(page.getByTestId('share-dialog')).toBeVisible();
|
||||
|
||||
// And the owner searches for the collaborator
|
||||
await page.getByTestId('share-search-input').fill('collab');
|
||||
await expect(page.getByTestId('share-user-result').first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// And the owner adds the collaborator
|
||||
await page.getByTestId('share-user-result').first().click();
|
||||
|
||||
// Then the collaborator appears in the share dialog
|
||||
await expect(page.getByTestId('share-collaborator')).toBeVisible();
|
||||
|
||||
// Close dialogs
|
||||
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note shows a collaborator indicator
|
||||
const card = noteCard(page, 'Shared Note');
|
||||
await expect(card.getByTestId('collaborator-indicator')).toBeVisible();
|
||||
// Then the owner sees a collaborator indicator
|
||||
await expect(noteCard(page, 'Share Test').getByTestId('collaborator-indicator')).toBeVisible();
|
||||
|
||||
// And the collaborator sees the shared note in their list
|
||||
await collabPage.reload();
|
||||
await collabPage.waitForLoadState('networkidle');
|
||||
await expect(noteCard(collabPage, 'Share Test')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator sees the shared note in their list', async ({ authenticatedPage: page, browser }) => {
|
||||
// Given the note was shared in the previous test
|
||||
// When the collaborator logs in
|
||||
const collab = await loginAsCollaborator(browser);
|
||||
collabContext = collab.context;
|
||||
collabPage = collab.page;
|
||||
test('Scenario: Collaborator can edit shared note and owner sees changes', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// Given a shared note exists
|
||||
const note = await createSharedNote(page, collabPage, 'Collab Edit Test');
|
||||
|
||||
// Then the collaborator sees "Shared Note" in their notes list
|
||||
await expect(noteCard(collabPage, 'Shared Note')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// And the note shows a collaborator indicator
|
||||
await expect(noteCard(collabPage, 'Shared Note').getByTestId('collaborator-indicator')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator can edit the shared note content', async ({ authenticatedPage: page }) => {
|
||||
// When the collaborator edits the note
|
||||
await noteCard(collabPage, 'Shared Note').click();
|
||||
const editor = collabPage.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially(' — edited by collaborator');
|
||||
await collabPage.getByTestId('close-editor-btn').click();
|
||||
|
||||
// And the owner reloads
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the owner sees the collaborator's changes
|
||||
await noteCard(page, 'Shared Note').click();
|
||||
await expect(page.getByTestId('tiptap-editor')).toContainText('edited by collaborator');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
|
||||
test('Scenario: Owner and collaborator have independent pin state', async ({ authenticatedPage: page }) => {
|
||||
// When the collaborator pins the shared note
|
||||
const card = noteCard(collabPage, 'Shared Note');
|
||||
await card.hover();
|
||||
await card.getByTestId('pin-btn').click({ force: true });
|
||||
|
||||
// Then the collaborator sees the note as pinned
|
||||
await expect(noteCard(collabPage, 'Shared Note').getByTestId('pin-indicator')).toBeVisible();
|
||||
|
||||
// But the owner does not see it as pinned
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const ownerCard = noteCard(page, 'Shared Note');
|
||||
await expect(ownerCard.getByTestId('pin-indicator')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Owner edits content and collaborator sees updated content via sync', async ({ authenticatedPage: page }) => {
|
||||
// When the owner edits the shared note content via the API
|
||||
const notesRes = await page.request.get('/api/notes');
|
||||
const allNotes = await notesRes.json();
|
||||
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
|
||||
expect(sharedNote).toBeDefined();
|
||||
|
||||
await page.request.patch(`/api/notes/${sharedNote.id}`, {
|
||||
data: { content: 'Content updated by owner for sync test' }
|
||||
// When the collaborator edits the note via API
|
||||
await collabPage.request.patch(`/api/notes/${note.id}`, {
|
||||
data: { content: 'Edited by collaborator' }
|
||||
});
|
||||
|
||||
// Then the collaborator sees the updated content after fetching from the API
|
||||
const collabNotesRes = await collabPage.request.get('/api/notes');
|
||||
const collabNotes = await collabNotesRes.json();
|
||||
const collabSharedNote = collabNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
expect(collabSharedNote).toBeDefined();
|
||||
expect(collabSharedNote.content).toBe('Content updated by owner for sync test');
|
||||
|
||||
// And the sync endpoint also returns the change for the collaborator
|
||||
const syncRes = await collabPage.request.get(`/api/sync?since=0`);
|
||||
const syncedNotes = await syncRes.json();
|
||||
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
expect(syncedNote).toBeDefined();
|
||||
expect(syncedNote.content).toBe('Content updated by owner for sync test');
|
||||
// Then the owner sees the updated content
|
||||
const ownerRes = await page.request.get(`/api/notes/${note.id}`);
|
||||
const ownerNote = await ownerRes.json();
|
||||
expect(ownerNote.content).toBe('Edited by collaborator');
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator edits content and owner sees updated content via sync', async ({ authenticatedPage: page }) => {
|
||||
// Given we have the shared note ID
|
||||
const notesRes = await page.request.get('/api/notes');
|
||||
const allNotes = await notesRes.json();
|
||||
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
|
||||
test('Scenario: Owner and collaborator have independent per-user state', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// Given a shared note exists
|
||||
const note = await createSharedNote(page, collabPage, 'Pin Independence Test');
|
||||
|
||||
// When the collaborator edits the content via the API
|
||||
await collabPage.request.patch(`/api/notes/${sharedNote.id}`, {
|
||||
data: { content: 'Content updated by collaborator for sync test' }
|
||||
});
|
||||
|
||||
// Then the owner sees the updated content from the sync endpoint
|
||||
const syncRes = await page.request.get(`/api/sync?since=0`);
|
||||
const syncedNotes = await syncRes.json();
|
||||
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
|
||||
expect(syncedNote).toBeDefined();
|
||||
expect(syncedNote.content).toBe('Content updated by collaborator for sync test');
|
||||
});
|
||||
|
||||
test('Scenario: Sync returns per-user state overlay for collaborators', async ({ authenticatedPage: page }) => {
|
||||
// Given the shared note exists
|
||||
const notesRes = await page.request.get('/api/notes');
|
||||
const allNotes = await notesRes.json();
|
||||
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
|
||||
|
||||
// And the owner pins the note (owner's pin state)
|
||||
await page.request.patch(`/api/notes/${sharedNote.id}`, {
|
||||
// When the collaborator pins the note
|
||||
await collabPage.request.patch(`/api/notes/${note.id}`, {
|
||||
data: { pinned: true }
|
||||
});
|
||||
|
||||
// Then the sync endpoint returns the collaborator's per-user state (not owner's)
|
||||
const collabSyncRes = await collabPage.request.get(`/api/sync?since=0`);
|
||||
const collabSyncedNotes = await collabSyncRes.json();
|
||||
const collabSyncedNote = collabSyncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
|
||||
// Then the collaborator sees it as pinned
|
||||
const collabRes = await collabPage.request.get(`/api/notes/${note.id}`);
|
||||
const collabNote = await collabRes.json();
|
||||
expect(collabNote.pinned).toBeTruthy();
|
||||
|
||||
// Collaborator should see their own pinned state (from noteUserState), not the owner's
|
||||
// The collaborator pinned this note in a previous test, so it should be true for them
|
||||
// but via their own per-user state, not leaked from the owner
|
||||
expect(collabSyncedNote).toBeDefined();
|
||||
// But the owner does not see it as pinned
|
||||
const ownerRes = await page.request.get(`/api/notes/${note.id}`);
|
||||
const ownerNote = await ownerRes.json();
|
||||
expect(ownerNote.pinned).toBeFalsy();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator sees leave action instead of trash', async () => {
|
||||
// Then the collaborator's card shows a leave button, not a trash button
|
||||
const card = noteCard(collabPage, 'Shared Note');
|
||||
test('Scenario: Sync returns correct data for both owner and collaborator', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// Given a shared note exists
|
||||
const note = await createSharedNote(page, collabPage, 'Sync Round-trip Test');
|
||||
|
||||
// When the owner updates the content
|
||||
await page.request.patch(`/api/notes/${note.id}`, {
|
||||
data: { content: 'Owner sync update' }
|
||||
});
|
||||
|
||||
// Then the collaborator sees the change via sync endpoint
|
||||
const syncRes = await collabPage.request.get(`/api/sync?since=0`);
|
||||
const syncedNotes = await syncRes.json();
|
||||
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === note.id);
|
||||
expect(syncedNote).toBeDefined();
|
||||
expect(syncedNote.content).toBe('Owner sync update');
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator sees leave action and can leave a shared note', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// Given a shared note exists
|
||||
const note = await createSharedNote(page, collabPage, 'Leave Test Note');
|
||||
|
||||
// When the collaborator views their notes
|
||||
await collabPage.reload();
|
||||
await collabPage.waitForLoadState('networkidle');
|
||||
await expect(noteCard(collabPage, 'Leave Test Note')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Then the collaborator's card shows a leave button, not trash
|
||||
const card = noteCard(collabPage, 'Leave Test Note');
|
||||
await card.hover();
|
||||
await expect(card.getByTestId('leave-btn')).toBeVisible();
|
||||
await expect(card.getByTestId('trash-btn')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator leaves shared note and it disappears', async ({ authenticatedPage: page }) => {
|
||||
// When the collaborator leaves the shared note
|
||||
const card = noteCard(collabPage, 'Shared Note');
|
||||
await card.hover();
|
||||
// When the collaborator leaves
|
||||
await card.getByTestId('leave-btn').click({ force: true });
|
||||
|
||||
// Then the note disappears from the collaborator's list
|
||||
await expect(noteCard(collabPage, 'Shared Note')).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(noteCard(collabPage, 'Leave Test Note')).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// But the owner still sees it
|
||||
// But the owner still has it
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(noteCard(page, 'Shared Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (collabContext) await collabContext.close();
|
||||
await expect(noteCard(page, 'Leave Test Note')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import istanbul from 'vite-plugin-istanbul';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -10,6 +11,17 @@ export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
...(process.env.VITE_COVERAGE === 'true'
|
||||
? [
|
||||
istanbul({
|
||||
include: 'src/**/*',
|
||||
exclude: ['node_modules', 'tests/', '**/*.test.ts'],
|
||||
extension: ['.ts', '.svelte'],
|
||||
requireEnv: true,
|
||||
forceBuildInstrument: true
|
||||
})
|
||||
]
|
||||
: []),
|
||||
SvelteKitPWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
|
||||
+8
-1
@@ -9,6 +9,13 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node'
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html', 'lcov', 'json-summary'],
|
||||
reportsDirectory: './coverage/unit',
|
||||
include: ['src/lib/**/*.ts'],
|
||||
exclude: ['src/lib/**/*.test.ts', 'src/lib/types/**']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+3
-3
@@ -145,8 +145,8 @@
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9z"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>
|
||||
</div>
|
||||
<h3>MCP Server</h3>
|
||||
<p>Built-in AI integration. Connect Claude, Cursor, or any MCP client to read and write your notes.</p>
|
||||
<h3>API & MCP</h3>
|
||||
<p>Full REST API with token auth. Integrate with n8n, Zapier, or scripts. Built-in MCP server for AI tools like Claude and Cursor.</p>
|
||||
</div>
|
||||
|
||||
<!-- Multi-User -->
|
||||
@@ -253,7 +253,7 @@
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.12.0</span>
|
||||
<span class="footer-version">v0.21.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user