Compare commits

..
23 Commits
Author SHA1 Message Date
github-actions[bot] 0cf34e631b chore: bump version to 0.21.0 2026-03-11 20:18:05 +00:00
copilot-swe-agent[bot]andfredicious 9f8fe85c44 fix: only create version snapshot when content actually changes
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:17:28 +01:00
copilot-swe-agent[bot]andfredicious 5ac7de7f78 chore: exclude package-lock.json from git (project uses pnpm)
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:17:28 +01:00
copilot-swe-agent[bot]andfredicious 5f6149e282 feat: add email notifications for user, account, and collaboration events
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:17:28 +01:00
copilot-swe-agent[bot]andfredicious 633727b58e fix: only create version snapshot when content actually changes
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:02:00 +01:00
github-actions[bot] 3ab7c1fb53 chore: pin docker-compose to v0.20.0 [skip ci] 2026-03-11 19:45:32 +00:00
github-actions[bot] d8c5a41fed chore: bump version to 0.20.0 2026-03-11 19:41:24 +00:00
Claude 0b1416908e feat: add email notifications when a note is shared
Send an email to collaborators when they are added to a shared note.
Uses nodemailer with optional SMTP configuration (auto-enabled when
SMTP_HOST is set). Users can opt out via the notifyOnShare preference.

https://claude.ai/code/session_01P4HSMr7QvKw9TMNikUJD9e
2026-03-11 20:33:48 +01:00
copilot-swe-agent[bot]andfredicious 07ea482bad fix: address code review feedback on NoteHistory component
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 20:28:36 +01:00
copilot-swe-agent[bot]andfredicious 0b34654736 feat: add note version history and restore (#note-history)
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 20:28:36 +01:00
Claude 8ed915bc69 fix: add vertical padding to checklist add-item button
https://claude.ai/code/session_0163L1u9MfL1C6Rv3yAw8RKs
2026-03-11 20:20:23 +01:00
Claude 917aef1865 fix: increase checklist item padding and fix text overflow in editor
Add more vertical padding (py-1 → py-1.5) to checklist items for better
readability. Add min-w-0 to the text input so long text is constrained
within the flex container instead of overflowing out of the note.

https://claude.ai/code/session_0163L1u9MfL1C6Rv3yAw8RKs
2026-03-11 20:20:23 +01:00
github-actions[bot] e8eec85d57 chore: pin docker-compose to v0.19.3 [skip ci] 2026-03-11 19:00:59 +00:00
github-actions[bot] b36326e65a chore: bump version to 0.19.3 2026-03-11 18:56:19 +00:00
Claude 26eff09d7d fix: resolve race condition in offline sync e2e test
Set up waitForResponse before calling setOffline(false) so the sync
response listener is registered before the online event fires.

https://claude.ai/code/session_014yTnHzjkCJLBcYcPTSLVkE
2026-03-11 19:53:47 +01:00
Claude 53aa32e07f fix: improve checklist note card text wrapping, spacing, and padding
- Replace `truncate` with `break-words min-w-0` so long checklist text
  wraps visibly instead of being clipped
- Increase vertical spacing between items (space-y-1 → space-y-2)
- Add bottom margin (mb-6) to prevent action buttons overlapping content
- Align checkboxes to top of multi-line items (items-start + mt-0.5)

https://claude.ai/code/session_01UVNA5wucVEjRQzwRxMhTZ9
2026-03-11 19:43:13 +01:00
Claude 51bfda3aec fix: prevent modal backdrop from catching the originating tap on mobile
On mobile, tapping a NoteCard to open the editor produces synthetic mouse
events (mousedown → mouseup → click) at the original touch coordinates.
If those coordinates land on the overlay backdrop rather than the editor
card, the mousedown sets mousedownOnOverlay = true and the click
immediately closes the editor — causing a brief flicker.

Guard against this by deferring overlay readiness to the next animation
frame after mount, so any synthetic events from the originating tap are
ignored.

https://claude.ai/code/session_011C8bfgShjDSmDr3rUCb2gw
2026-03-11 19:24:06 +01:00
github-actions[bot] 617c121221 chore: pin docker-compose to v0.19.2 [skip ci] 2026-03-10 22:47:34 +00:00
github-actions[bot] 5a9c6d2a36 chore: bump version to 0.19.2 2026-03-10 22:43:26 +00:00
Frederic CabassutandClaude Opus 4.6 7c2dad47cc ci: use Playwright container image instead of downloading browsers
Run E2E tests inside mcr.microsoft.com/playwright container which has
Chromium pre-installed, eliminating the ~30s browser download on each run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:39:29 +01:00
Frederic CabassutandClaude Opus 4.6 370b0bc815 fix: eliminate e2e test flakiness in settings preference and API key tests
Three sources of flakiness fixed:

1. **Preferences sync race** — page.goto() triggers a full reload which
   re-runs initPreferences() + syncPreferencesFromServer(). On slow CI,
   the async server sync can return stale data from parallel test workers
   that share the same user's server-side preferences. Fixed with
   gotoWithStablePrefs() which blocks the one GET /api/preferences sync
   request, so initPreferences() falls back to localStorage (always correct).

2. **localStorage not reset** — resetPreferences() only reset the server.
   After a full page reload, loadFromLocalStorage() could return stale
   values from previous test runs. Now resets both server and localStorage.

3. **API key name collision** — parallel test runs created keys with the
   same name ("Settings Test Key"), causing Playwright strict-mode
   violations when multiple elements matched. Fixed with Date.now()
   unique suffixes.

Verified: 167/167 settings tests pass (20x repeat), 173/173 full suite (5x).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:39:29 +01:00
Frederic CabassutandClaude Opus 4.6 fc391d675c docs: add E2E BDD testing best practices and improve test suite
- Add docs/TESTING.md with 10 BDD testing best practices
- Add createNote() helper to fixtures to DRY up state setup
- Add createChecklistNote() helper in checklist tests
- Rename scenario titles to describe outcomes, not actions
- Replace imperative Given steps with createNote() helper
- Fix fragile conditional in tag filtering test (silent pass)
- Remove implementation details from Gherkin comments
- Improve Given/When/Then rhythm consistency across all tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:39:29 +01:00
github-actions[bot] 2995479b1d chore: pin docker-compose to v0.19.0 [skip ci] 2026-03-10 16:07:11 +00:00
46 changed files with 14817 additions and 195 deletions
+8
View File
@@ -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>
+3 -15
View File
@@ -62,6 +62,8 @@ jobs:
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.0-noble
steps:
- uses: actions/checkout@v4
@@ -76,21 +78,6 @@ 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:
@@ -102,6 +89,7 @@ jobs:
env:
CI: true
VITE_COVERAGE: 'true'
HOME: /root
- name: Merge E2E coverage
if: always()
+1
View File
@@ -44,3 +44,4 @@ todo.md
# Worktrees
.worktrees/
package-lock.json
+6 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.17.0
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:
+14
View File
@@ -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`:
+1
View File
@@ -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
View File
@@ -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 |
+13070
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.19.1",
"version": "0.21.0",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
@@ -54,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",
@@ -68,6 +69,7 @@
"@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",
+19
View File
@@ -79,6 +79,9 @@ importers:
markdown-it:
specifier: ^14.1.1
version: 14.1.1
nodemailer:
specifier: ^8.0.2
version: 8.0.2
svelte-dnd-action:
specifier: ^0.9.69
version: 0.9.69(svelte@5.53.7)
@@ -116,6 +119,9 @@ importers:
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
'@types/nodemailer':
specifier: ^7.0.11
version: 7.0.11
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
@@ -1653,6 +1659,9 @@ packages:
'@types/node@25.3.5':
resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==}
'@types/nodemailer@7.0.11':
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -3005,6 +3014,10 @@ packages:
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
nodemailer@8.0.2:
resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==}
engines: {node: '>=6.0.0'}
nyc@18.0.0:
resolution: {integrity: sha512-G5UyHinFkB1BxqGTrmZdB6uIYH0+v7ZnVssuflUDi+J+RhKWyAhRT1RCehBSI6jLFLuUUgFDyLt49mUtdO1XeQ==}
engines: {node: 20 || >=22}
@@ -5435,6 +5448,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/nodemailer@7.0.11':
dependencies:
'@types/node': 25.3.5
'@types/resolve@1.20.2': {}
'@types/trusted-types@2.0.7': {}
@@ -6770,6 +6787,8 @@ snapshots:
node-releases@2.0.36: {}
nodemailer@8.0.2: {}
nyc@18.0.0:
dependencies:
'@istanbuljs/load-nyc-config': 1.1.0
+4 -4
View File
@@ -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}
+4 -4
View File
@@ -138,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}
+71
View File
@@ -5,6 +5,7 @@
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';
@@ -19,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;
@@ -55,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);
@@ -84,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)) {
@@ -142,7 +176,24 @@
// 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;
}
@@ -364,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
@@ -386,3 +449,11 @@
onUpdate={handleCollaboratorsUpdate}
/>
{/if}
{#if showHistory && noteId}
<NoteHistory
noteId={noteId}
onClose={() => (showHistory = false)}
onRestored={handleHistoryRestored}
/>
{/if}
+224
View File
@@ -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>
+14
View File
@@ -123,6 +123,18 @@ sqlite.exec(`
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
@@ -196,6 +208,8 @@ sqlite.exec(`
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,
user_id INTEGER NOT NULL REFERENCES users(id),
+20
View File
@@ -189,3 +189,23 @@ export const userPreferences = sqliteTable(
},
(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)
]
);
+13
View File
@@ -136,6 +136,19 @@ export function createTestDb(options?: { seedUser?: boolean }) {
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 });
+353
View File
@@ -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>&ldquo;${escapedTitle}&rdquo;</strong>
</p>
${buildOpenButton(appUrl)}`;
return buildEmailLayout(content, 'You&rsquo;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&rsquo;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&rsquo;re receiving this because an admin reset your password on Crumbs. If you didn&rsquo;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&rsquo;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&rsquo;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&rsquo;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>&ldquo;${escapedTitle}&rdquo;</strong>. You no longer have access to this note.
</p>
${buildOpenButton(appUrl)}`;
return buildEmailLayout(content, 'You&rsquo;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>&ldquo;${escapedTitle}&rdquo;</strong> has been permanently deleted by its owner. You no longer have access to it.
</p>`;
return buildEmailLayout(content, 'You&rsquo;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&rsquo;t you, please change your password once you regain access.
</p>`;
return buildEmailLayout(content, 'You&rsquo;re receiving this security alert because of repeated failed login attempts on your Crumbs account.');
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+26 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteCollaborators, noteUserState, users } from './db/schema.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 {
@@ -233,6 +233,31 @@ describe('updateNote', () => {
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' });
+21
View File
@@ -7,6 +7,7 @@ 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';
interface NoteRow {
@@ -293,6 +294,26 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
}
}
// 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);
}
+207
View File
@@ -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');
});
});
+118
View File
@@ -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;
}
+7 -1
View File
@@ -7,12 +7,18 @@ describe('preferences defaults', () => {
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
sidebarDefaultState: 'open'
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);
+20
View File
@@ -98,5 +98,25 @@ 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';
+11 -2
View File
@@ -5,15 +5,24 @@ export interface UserPreferences {
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'
sidebarDefaultState: 'open',
notifyOnShare: true,
notifyOnCollabRemoved: true,
notifyOnNoteDeleted: true
};
export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([
'hideFooter'
'hideFooter',
'notifyOnShare',
'notifyOnCollabRemoved',
'notifyOnNoteDeleted'
]);
@@ -64,4 +64,42 @@
/>
<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>
+8
View File
@@ -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) {
+9
View File
@@ -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 });
};
+24
View File
@@ -3,6 +3,9 @@ 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();
@@ -23,6 +26,27 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
const user = await verifyPassword(email, password);
if (!user) {
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');
}
+11 -1
View File
@@ -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 });
};
+27
View File
@@ -3,6 +3,9 @@ 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);
@@ -25,8 +28,32 @@ export const DELETE: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
requireNoteOwnership(db, params.id, userId);
// 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 });
};
@@ -5,6 +5,12 @@ 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);
@@ -48,6 +54,39 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
addCollaborator(db, params.id, targetUserId, userId);
// 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 });
};
@@ -67,7 +106,50 @@ export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
throw error(403, 'Only the owner can remove other collaborators');
}
// 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);
};
+3 -7
View File
@@ -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();
});
});
+4 -3
View File
@@ -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
View File
@@ -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();
+13
View File
@@ -90,4 +90,17 @@ export async function setupAndLogin(page: Page) {
await page.waitForLoadState('networkidle');
}
/** 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 };
+4 -16
View File
@@ -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');
+18 -32
View File
@@ -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();
});
});
+17 -37
View File
@@ -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,58 +27,42 @@ 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: Most recently updated note appears first', 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();
// 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();
// 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();
+4 -14
View File
@@ -1,15 +1,10 @@
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');
@@ -20,12 +15,7 @@ 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');
+67 -22
View File
@@ -1,7 +1,20 @@
import { test, expect } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
/** Reset all preferences to defaults via API so each test starts clean */
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: {
@@ -11,6 +24,31 @@ async function resetPreferences(page: Page) {
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', () => {
@@ -30,13 +68,13 @@ test.describe('Settings — Preferences', () => {
await resetPreferences(page);
// Given the user sets default note mode to Markdown
await page.goto('/settings/preferences');
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 page.goto('/');
await gotoWithStablePrefs(page, '/');
await page.getByTestId('new-note-btn').click();
// Then the note editor opens in markdown mode (textarea visible)
@@ -49,23 +87,23 @@ test.describe('Settings — Preferences', () => {
await resetPreferences(page);
// Given the footer is visible
await page.goto('/');
await gotoWithStablePrefs(page, '/');
await expect(page.getByTestId('app-footer')).toBeVisible();
// When the user enables the hide footer preference
await page.goto('/settings/preferences');
await gotoWithStablePrefs(page, '/settings/preferences');
await page.getByTestId('pref-hide-footer').check();
// Then the footer is hidden
await page.goto('/');
await gotoWithStablePrefs(page, '/');
await expect(page.getByTestId('app-footer')).not.toBeVisible();
// When the user disables the hide footer preference
await page.goto('/settings/preferences');
await gotoWithStablePrefs(page, '/settings/preferences');
await page.getByTestId('pref-hide-footer').uncheck();
// Then the footer is visible again
await page.goto('/');
await gotoWithStablePrefs(page, '/');
await expect(page.getByTestId('app-footer')).toBeVisible();
});
@@ -75,12 +113,13 @@ test.describe('Settings — Preferences', () => {
await resetPreferences(page);
// Given the user changes default note mode to Markdown
await page.goto('/settings/preferences');
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
// 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)
@@ -93,9 +132,12 @@ 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
@@ -104,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
@@ -128,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 redirects to preferences
// 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 API nav link
// When the user selects the API tab
await page.getByRole('link', { name: 'API' }).click();
// Then the API 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();
// Then the profile settings are displayed
await expect(page).toHaveURL('/settings/profile');
});
});
+1 -1
View File
@@ -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.19.1</span>
<span class="footer-version">v0.21.0</span>
</div>
</footer>