Compare commits

..
21 Commits
Author SHA1 Message Date
github-actions[bot] 7f2c7410c6 chore: bump version to 0.11.1 2026-03-09 20:12:19 +00:00
Frederic CabassutandClaude Opus 4.6 da7feea8e5 fix: sync endpoint returns per-user state overlay for shared notes
getChangesSince was returning the owner's pinned/archived/sortOrder
for shared notes instead of the collaborator's per-user values from
noteUserState. Also filtered trashed notes from collaborator sync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:06:04 +01:00
github-actions[bot] d7b9cc44a9 chore: pin docker-compose to v0.11.0 [skip ci] 2026-03-09 11:18:27 +00:00
github-actions[bot] 27391bb179 chore: bump version to 0.11.0 2026-03-09 11:12:31 +00:00
Frederic CabassutandClaude Opus 4.6 a66256495e docs: add collaboration to FEATURES, ARCHITECTURE, and OpenAPI spec
- Add note sharing section to FEATURES.md
- Document collaboration access control model in ARCHITECTURE.md
- Add collaborators and user search endpoints to openapi.yaml
- Regenerate API.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 51981e0863 test: add e2e tests for note sharing workflow
Serial test suite covering: owner shares note, collaborator sees it,
collaborator edits content, independent pin state, leave action shown
for non-owners, collaborator leaves and note disappears.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 9390d9851f test: add unit tests for collaboration schema tables
Test noteCollaborators and noteUserState tables: CRUD, unique constraints,
defaults, and independent per-user state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 1811548207 feat: add sharing UI components and integrate into NoteCard/NoteEditor
- Create ShareDialog with user search autocomplete, collaborator list, and remove
- Create CollaboratorPopover for hover/click indicator on shared notes
- Add collaborator indicator to NoteCard (always visible Users icon)
- Replace trash with Leave action for non-owner shared notes
- Add share button to NoteEditor toolbar (UserPlus → Users when shared)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 7ef4e00c4f feat: widen notes service and sync for shared notes
- Refactor notes-service to include shared notes in list/get/search/reorder
- Route per-user fields (pinned/archived/sortOrder) to noteUserState for collaborators
- Update API routes to delegate to service layer with access control
- Update sync server to handle shared note updates bidirectionally
- Add leaveNote() to client store
- Refactor search and notes API routes to use service layer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 a612a16b81 feat: add note collaboration schema, access control, and API routes
Add noteCollaborators and noteUserState tables for sharing notes between
users. Implement access control helpers (canAccessNote, requireNoteAccess,
requireNoteOwnership) and collaborators CRUD service. Create API endpoints
for managing collaborators and searching users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 a1ae3a0069 docs: remove Google Keep references from website and docs
Replace brand-specific "Google Keep alternative" copy with generic
privacy-focused messaging across meta tags, hero subtitle, README,
and feature docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:08:42 +01:00
Frederic CabassutandClaude Opus 4.6 6c20699e2b fix: use apple-touch-icon as og:image for link previews
Platforms like WhatsApp require an explicit og:image to display
anything in link previews. Point it to the existing apple-touch-icon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:04:34 +01:00
Frederic CabassutandClaude Opus 4.6 8afa4bd66d fix: remove OG image and let apple-touch-icon handle link previews
The apple-touch-icon already provides the pretzel logo for link
previews (like the app does). Remove the redundant OG image and switch
twitter:card to "summary" (icon-sized) instead of "summary_large_image".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:03:08 +01:00
Frederic CabassutandClaude Opus 4.6 687eb1c52d fix: simplify OG image to just the pretzel logo on parchment
Title and description are already shown in the link preview text,
so the image doesn't need to repeat them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:02:15 +01:00
Frederic CabassutandClaude Opus 4.6 52fb97c347 fix: add apple-touch-icon to website for link preview icon
Messaging apps (WhatsApp, iMessage, etc.) use the apple-touch-icon to
display a site icon in link previews. Copy the existing icon from the
app and add the link tag to the landing page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:58:19 +01:00
Frederic CabassutandClaude Opus 4.6 2ef6830b26 feat: add OG image and Twitter Card meta tags for link previews
The og-image.png was referenced in meta tags but never created. Generate
a 1200x630 parchment-themed OG image and add missing twitter:title,
twitter:description, and twitter:image meta tags for proper social
media link previews.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:54:47 +01:00
github-actions[bot] 56bbd07114 chore: pin docker-compose to v0.10.1 [skip ci] 2026-03-09 10:52:33 +00:00
github-actions[bot] 9c2028e187 chore: bump version to 0.10.1 2026-03-09 10:48:47 +00:00
Frederic CabassutandClaude Opus 4.6 cb9857324a fix: migrate password_hash column to nullable for OAuth-only users
Existing databases created from the original migration had password_hash
as NOT NULL. This adds an auto-migration that recreates the users table
with a nullable password_hash column, enabling OAuth-only user creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:47:48 +01:00
Frederic CabassutandClaude Opus 4.6 e90c9c041c docs: use Authentik OIDC example in README instead of Google OAuth
Self-hosted auth provider is more relevant for a self-hosted app.
Also adds AUTH.md to the documentation table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:42:25 +01:00
github-actions[bot] d9b3bd9ff6 chore: pin docker-compose to v0.10.0 [skip ci] 2026-03-09 10:42:14 +00:00
32 changed files with 2079 additions and 340 deletions
+9 -4
View File
@@ -9,7 +9,7 @@
# Crumbs by Bretzel
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
## Install
@@ -21,7 +21,7 @@ docker compose up -d
Open http://localhost:3000 and create your admin account on first visit.
#### With OAuth (optional)
#### With SSO (optional)
```yaml
# docker-compose.yml
@@ -34,14 +34,18 @@ services:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
- AUTH_GOOGLE_ID=your-google-client-id
- AUTH_GOOGLE_SECRET=your-google-client-secret
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=your-client-id
- AUTH_OIDC_CLIENT_SECRET=your-client-secret
- AUTH_OIDC_DISPLAY_NAME=Authentik
restart: unless-stopped
volumes:
crumbs-data:
```
Google and GitHub OAuth are also supported. See [docs/AUTH.md](docs/AUTH.md) for all providers and setup guides.
### Manual (Node.js)
```bash
@@ -99,6 +103,7 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|----------|-------------|
| [Features](docs/FEATURES.md) | Detailed feature list and behavior |
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
| [Auth](docs/AUTH.md) | Password auth, OAuth/SSO setup (Authentik, Keycloak, etc.) |
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
| [API](docs/API.md) | REST API reference (auto-generated) |
| [Contributing](CONTRIBUTING.md) | How to contribute |
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.9.1
image: ghcr.io/bretzel-app/crumbs:0.11.0
container_name: crumbs
ports:
- "3000:3000"
+117
View File
@@ -321,6 +321,123 @@ Permanently delete a note
---
## Collaboration
### `GET /api/notes/{id}/collaborators`
List collaborators
Returns all collaborators for a note. Requires access (owner or collaborator).
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Response:** `200`
```json
[
{
"userId": 1,
"displayName": "displayName",
"email": "email",
"addedAt": "2025-01-01T00:00:00.000Z"
}
]
```
---
### `POST /api/notes/{id}/collaborators`
Add a collaborator
Owner-only. Adds a user as collaborator on the note.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Request:**
```json
{
"userId": 1
}
```
**Response:** `201`
```json
[
{
"userId": 1,
"displayName": "displayName",
"email": "email",
"addedAt": "2025-01-01T00:00:00.000Z"
}
]
```
**Errors:** `400` Invalid request (missing userId, owner as collaborator), `409` User is already a collaborator
---
### `DELETE /api/notes/{id}/collaborators`
Remove a collaborator
Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
| `userId` | query | | yes | |
**Response:** `200`
```json
{
"success": true
}
```
---
## Users
### `GET /api/users/search`
Search users
Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `q` | query | string | yes | Search query (matches email or displayName) |
**Response:** `200`
```json
[
{
"id": 1,
"displayName": "displayName",
"email": "email"
}
]
```
---
## Attachments
### `GET /api/notes/{id}/attachments`
+13
View File
@@ -98,8 +98,21 @@ Key tables:
- `note_tags` - Many-to-many note ↔ tag
- `attachments` - Image file metadata (path + thumbnailPath)
- `api_keys` - MCP API keys (SHA-256 hashed, prefix for display)
- `note_collaborators` - Sharing relationships (noteId + userId + addedBy)
- `note_user_state` - Per-user pin/archive/sortOrder for shared notes
- `sync_log` - Sync operation history
### Collaboration Access Control
Notes support sharing with other users on the same instance:
- **Owner**: Full control (edit, trash, delete, share/unshare)
- **Collaborator**: Can edit content, color, checklist mode; has independent pin/archive/sortOrder state via `note_user_state`
- Access checks use `canAccessNote()` / `requireNoteAccess()` / `requireNoteOwnership()` helpers
- `note_user_state` stores per-user view state (pinned, archived, sortOrder) — collaborators see their own organization
- When owner trashes a note, it vanishes for all collaborators
- Collaborators can leave (remove themselves) from shared notes
## MCP Server
Crumbs includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server that allows AI assistants (Claude Code, Claude Desktop, etc.) to interact with notes programmatically.
+13 -1
View File
@@ -59,7 +59,7 @@
- Tags inside code blocks and URLs are ignored
### Color-Coded Notes
12 colors matching Google Keep's palette:
12 note colors:
- Default, Coral, Peach, Sand, Mint, Sage
- Fog, Storm, Dusk, Blossom, Clay, Chalk
- Colors adapt to note theme
@@ -80,6 +80,18 @@
- Restore notes from trash
- Permanently delete from trash
### Note Sharing / Collaboration
- Share notes with other users on the same instance
- User search autocomplete for adding collaborators
- Full collaboration: both owner and collaborators can edit content, color, and checklist mode
- Per-user state: pin, archive, and sort order are independent per user
- Owner-only actions: trash, delete, share/unshare
- Collaborator indicator (Users icon) on shared note cards
- Hover popover showing collaborator names
- Share dialog accessible from editor toolbar
- Collaborators can leave shared notes
- Shared notes appear mixed in main list alongside owned notes
### Full-Text Search
- Search across note titles, content, and tags
- Real-time search results as you type
+111
View File
@@ -168,6 +168,104 @@ paths:
'404':
description: Note not found
/api/notes/{id}/collaborators:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
get:
summary: List collaborators
description: Returns all collaborators for a note. Requires access (owner or collaborator).
tags: [Collaboration]
responses:
'200':
description: Collaborator list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Collaborator'
post:
summary: Add a collaborator
description: Owner-only. Adds a user as collaborator on the note.
tags: [Collaboration]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [userId]
properties:
userId:
type: integer
responses:
'201':
description: Collaborator added, returns updated list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Collaborator'
'400':
description: Invalid request (missing userId, owner as collaborator)
'409':
description: User is already a collaborator
delete:
summary: Remove a collaborator
description: Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
tags: [Collaboration]
parameters:
- name: userId
in: query
required: true
schema:
oneOf:
- type: integer
- type: string
enum: [self]
responses:
'200':
description: Collaborator removed
content:
application/json:
schema:
$ref: '#/components/schemas/Success'
/api/users/search:
get:
summary: Search users
description: Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
tags: [Users]
parameters:
- name: q
in: query
required: true
schema:
type: string
description: Search query (matches email or displayName)
responses:
'200':
description: Matching users (max 10)
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
displayName:
type: string
email:
type: string
/api/notes/{id}/attachments:
parameters:
- name: id
@@ -542,6 +640,19 @@ components:
data:
$ref: '#/components/schemas/NoteInput'
Collaborator:
type: object
properties:
userId:
type: integer
displayName:
type: string
email:
type: string
addedAt:
type: string
format: date-time
ApiKey:
type: object
properties:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.10.0",
"version": "0.11.1",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
@@ -0,0 +1,68 @@
<script lang="ts">
import type { Collaborator } from '$lib/types/index.js';
import Users from 'lucide-svelte/icons/users';
interface Props {
collaborators: Collaborator[];
ownerName?: string;
}
const { collaborators, ownerName }: Props = $props();
let showPopover = $state(false);
let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
function handleMouseEnter() {
hoverTimeout = setTimeout(() => {
showPopover = true;
}, 150);
}
function handleMouseLeave() {
clearTimeout(hoverTimeout);
showPopover = false;
}
function handleClick(e: Event) {
e.stopPropagation();
showPopover = !showPopover;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative inline-flex"
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
>
<button
type="button"
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--primary)]"
title="Shared note"
onclick={handleClick}
data-testid="collaborator-indicator"
>
<Users class="h-4 w-4" />
</button>
{#if showPopover}
<div
class="absolute left-0 bottom-full z-20 mb-1 min-w-[160px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
data-testid="collaborator-popover"
>
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
<ul class="space-y-0.5">
{#if ownerName}
<li class="text-xs text-[var(--text)]">
{ownerName} <span class="text-[var(--text-muted)]">(Owner)</span>
</li>
{/if}
{#each collaborators as collab}
<li class="text-xs text-[var(--text)]">
{collab.displayName || collab.email}
</li>
{/each}
</ul>
</div>
{/if}
</div>
+41 -19
View File
@@ -1,15 +1,17 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
import TagChip from './TagChip.svelte';
import ImageLightbox from './ImageLightbox.svelte';
import CollaboratorPopover from './CollaboratorPopover.svelte';
import type { Note } from '$lib/types/index.js';
import Undo2 from 'lucide-svelte/icons/undo-2';
import Trash2 from 'lucide-svelte/icons/trash-2';
import Bookmark from 'lucide-svelte/icons/bookmark';
import Archive from 'lucide-svelte/icons/archive';
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
import UserMinus from 'lucide-svelte/icons/user-minus';
interface ChecklistItem {
text: string;
@@ -112,16 +114,25 @@
</div>
{/if}
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="absolute top-1.5 right-1.5 rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unpin"
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
</button>
{/if}
<!-- Status indicators (top-right) -->
<div class="absolute top-1.5 right-1.5 flex items-center gap-0.5">
{#if note.isShared && note.collaborators}
<CollaboratorPopover
collaborators={note.collaborators}
ownerName={note.isOwner ? 'You' : undefined}
/>
{/if}
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unpin"
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
</button>
{/if}
</div>
{#if note.title}
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
@@ -208,14 +219,25 @@
<Archive class="h-4 w-4" />
</button>
{/if}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete"
data-testid="trash-btn"
>
<Trash2 class="h-4 w-4" />
</button>
{#if note.isShared && !note.isOwner}
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Leave note"
data-testid="leave-btn"
>
<UserMinus class="h-4 w-4" />
</button>
{:else}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete"
data-testid="trash-btn"
>
<Trash2 class="h-4 w-4" />
</button>
{/if}
{/if}
</div>
+65 -1
View File
@@ -4,16 +4,20 @@
import FormattingToolbar from './FormattingToolbar.svelte';
import TiptapEditor from './TiptapEditor.svelte';
import ImageUpload from './ImageUpload.svelte';
import ShareDialog from './ShareDialog.svelte';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { notes } from '$lib/stores/notes.js';
import { NOTE_COLORS } from '$lib/utils/colors.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor, Attachment } from '$lib/types/index.js';
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
import Palette from 'lucide-svelte/icons/palette';
import SquareCheck from 'lucide-svelte/icons/square-check';
import ImageIcon from 'lucide-svelte/icons/image';
import Type from 'lucide-svelte/icons/type';
import FileCode from 'lucide-svelte/icons/file-code';
import FileText from 'lucide-svelte/icons/file-text';
import UserPlus from 'lucide-svelte/icons/user-plus';
import Users from 'lucide-svelte/icons/users';
interface Props {
note: Note | null;
@@ -48,6 +52,36 @@
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
let showShareDialog = $state(false);
// svelte-ignore state_referenced_locally
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
const isOwner = $derived(note?.isOwner !== false);
const isShared = $derived(collaboratorsList.length > 0);
// Use displayName from first user or 'You' for owner
const ownerName = $derived(isOwner ? 'You' : 'Owner');
function handleCollaboratorsUpdate(updated: Collaborator[]) {
collaboratorsList = updated;
// Update the note in the store
if (noteId) {
notes.update((list) =>
list.map((n) =>
n.id === noteId
? { ...n, collaborators: updated, isShared: updated.length > 0 }
: n
)
);
}
}
async function toggleShareDialog() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showShareDialog = !showShareDialog;
}
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
$effect(() => {
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
@@ -285,6 +319,26 @@
<FileCode class="h-5 w-5" />
{/if}
</button>
<!-- Share button (owner only for new shares, all for viewing) -->
{#if isOwner}
<button
onclick={toggleShareDialog}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Share note"
data-testid="share-toggle"
>
{#if isShared}
<Users class="h-5 w-5 text-[var(--primary)]" />
{:else}
<UserPlus class="h-5 w-5" />
{/if}
</button>
{:else if isShared}
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
<Users class="h-5 w-5" />
</span>
{/if}
</div>
<button
@@ -297,3 +351,13 @@
</div>
</div>
</div>
{#if showShareDialog && noteId}
<ShareDialog
noteId={noteId}
collaborators={collaboratorsList}
{ownerName}
onClose={() => (showShareDialog = false)}
onUpdate={handleCollaboratorsUpdate}
/>
{/if}
+191
View File
@@ -0,0 +1,191 @@
<script lang="ts">
import type { Collaborator } from '$lib/types/index.js';
import X from 'lucide-svelte/icons/x';
import UserMinus from 'lucide-svelte/icons/user-minus';
import Search from 'lucide-svelte/icons/search';
import { showToast } from '$lib/stores/toast.js';
interface UserResult {
id: number;
displayName: string;
email: string;
}
interface Props {
noteId: string;
collaborators: Collaborator[];
ownerName: string;
onClose: () => void;
onUpdate: (collaborators: Collaborator[]) => void;
}
const { noteId, collaborators, ownerName, onClose, onUpdate }: Props = $props();
let searchQuery = $state('');
let searchResults = $state<UserResult[]>([]);
let isSearching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function handleSearch(query: string) {
clearTimeout(searchTimeout);
if (!query.trim()) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
isSearching = true;
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
const users: UserResult[] = await res.json();
// Filter out existing collaborators
const collabIds = new Set(collaborators.map((c) => c.userId));
searchResults = users.filter((u) => !collabIds.has(u.id));
}
} catch {
// Search failed silently
} finally {
isSearching = false;
}
}, 300);
}
$effect(() => {
handleSearch(searchQuery);
});
async function addCollaborator(user: UserResult) {
try {
const res = await fetch(`/api/notes/${noteId}/collaborators`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id })
});
if (res.ok) {
const updated: Collaborator[] = await res.json();
onUpdate(updated);
searchQuery = '';
searchResults = [];
showToast(`Shared with ${user.displayName || user.email}`, 'success');
} else {
const err = await res.json().catch(() => ({ message: 'Failed to share' }));
showToast(err.message || 'Failed to share', 'error');
}
} catch {
showToast('Failed to share note', 'error');
}
}
async function removeCollaborator(userId: number) {
try {
const res = await fetch(`/api/notes/${noteId}/collaborators?userId=${userId}`, {
method: 'DELETE'
});
if (res.ok) {
const updated = collaborators.filter((c) => c.userId !== userId);
onUpdate(updated);
}
} catch {
showToast('Failed to remove collaborator', 'error');
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</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 animate-[fade-in_150ms_ease-out]"
onclick={onClose}
onkeydown={handleKeydown}
data-testid="share-dialog-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
data-testid="share-dialog"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
title="Close"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Search input -->
<div class="relative px-4 pt-3">
<div class="relative">
<Search class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
<input
type="text"
placeholder="Search users..."
bind:value={searchQuery}
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] py-2 pl-8 pr-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="share-search-input"
/>
</div>
<!-- Search results dropdown -->
{#if searchResults.length > 0}
<ul class="absolute left-4 right-4 z-10 mt-1 max-h-40 overflow-y-auto rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]" data-testid="share-search-results">
{#each searchResults as user}
<li>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--primary)]/10"
onclick={() => addCollaborator(user)}
data-testid="share-user-result"
>
<span class="font-medium">{user.displayName || 'User'}</span>
{#if user.email}
<span class="ml-1 text-[var(--text-muted)]">{user.email}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Current collaborators -->
<div class="px-4 py-3">
<p class="mb-2 text-xs font-semibold text-[var(--text-muted)]">People with access</p>
<ul class="space-y-1">
<!-- Owner -->
<li class="flex items-center justify-between rounded-sm px-2 py-1.5">
<span class="text-sm text-[var(--text)]">
{ownerName}
<span class="text-xs text-[var(--text-muted)]">(Owner)</span>
</span>
</li>
<!-- Collaborators -->
{#each collaborators as collab}
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
<span class="text-sm text-[var(--text)]">
{collab.displayName || collab.email}
</span>
<button
onclick={() => removeCollaborator(collab.userId)}
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--destructive)]"
title="Remove"
data-testid="remove-collaborator-btn"
>
<UserMinus class="h-4 w-4" />
</button>
</li>
{/each}
</ul>
</div>
</div>
</div>
+36
View File
@@ -1,4 +1,7 @@
import { error } from '@sveltejs/kit';
import { db } from './db/index.js';
import { notes, noteCollaborators } from './db/schema.js';
import { eq, and } from 'drizzle-orm';
type EventWithLocals = {
locals: App.Locals;
@@ -14,3 +17,36 @@ export function requireAdmin(event: EventWithLocals) {
if (!user || user.role !== 'admin') throw error(403, 'Forbidden');
return user;
}
/**
* Check if a user can access a note (as owner or collaborator).
*/
export function canAccessNote(noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
if (!note) return { canAccess: false, isOwner: false };
if (note.userId === userId) return { canAccess: true, isOwner: true };
const collab = db
.select({ userId: noteCollaborators.userId })
.from(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
.get();
return { canAccess: !!collab, isOwner: false };
}
/**
* Require that a user can access a note. Throws 404 if no access.
*/
export function requireNoteAccess(noteId: string, userId: number): { isOwner: boolean } {
const { canAccess, isOwner } = canAccessNote(noteId, userId);
if (!canAccess) throw error(404, 'Not found');
return { isOwner };
}
/**
* Require that a user is the owner of a note. Throws 404 if not owner.
*/
export function requireNoteOwnership(noteId: string, userId: number): void {
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
if (!note || note.userId !== userId) throw error(404, 'Not found');
}
+75
View File
@@ -0,0 +1,75 @@
import { db } from './db/index.js';
import { noteCollaborators, noteUserState, users } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import type { Collaborator } from '$lib/types/index.js';
/**
* Batch fetch collaborators for multiple notes.
* Returns a Map of noteId → Collaborator[].
*/
export function fetchCollaboratorsForNotes(noteIds: string[]): Map<string, Collaborator[]> {
if (noteIds.length === 0) return new Map();
const rows = db
.select({
noteId: noteCollaborators.noteId,
userId: noteCollaborators.userId,
displayName: users.displayName,
email: users.email,
addedAt: noteCollaborators.addedAt
})
.from(noteCollaborators)
.innerJoin(users, eq(noteCollaborators.userId, users.id))
.where(inArray(noteCollaborators.noteId, noteIds))
.all();
const map = new Map<string, Collaborator[]>();
for (const row of rows) {
const collab: Collaborator = {
userId: row.userId,
displayName: row.displayName,
email: row.email,
addedAt: row.addedAt
};
const existing = map.get(row.noteId);
if (existing) {
existing.push(collab);
} else {
map.set(row.noteId, [collab]);
}
}
return map;
}
/**
* Add a collaborator to a note.
*/
export function addCollaborator(noteId: string, userId: number, addedBy: number): void {
db.insert(noteCollaborators)
.values({ noteId, userId, addedBy, addedAt: new Date() })
.run();
}
/**
* Remove a collaborator from a note. Also cleans up their per-user state.
*/
export function removeCollaborator(noteId: string, userId: number): void {
db.delete(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
.run();
db.delete(noteUserState)
.where(and(eq(noteUserState.noteId, noteId), eq(noteUserState.userId, userId)))
.run();
}
/**
* Get all collaborator user IDs for a note.
*/
export function getCollaboratorIds(noteId: string): number[] {
return db
.select({ userId: noteCollaborators.userId })
.from(noteCollaborators)
.where(eq(noteCollaborators.noteId, noteId))
.all()
.map((r) => r.userId);
}
+154
View File
@@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './test-helpers.js';
import { notes, noteCollaborators, noteUserState, users } from './schema.js';
import { eq, and } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>;
function seedTwoUsers(db: BetterSQLite3Database<typeof schema>) {
db.insert(users)
.values([
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
{ email: 'collab@test.com', displayName: 'Collaborator', role: 'user', authProvider: 'password', createdAt: new Date() }
])
.run();
}
function seedNote(db: BetterSQLite3Database<typeof schema>, id: string, userId: number) {
const now = new Date();
db.insert(notes).values({ id, userId, title: 'Test', content: '', createdAt: now, updatedAt: now }).run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedTwoUsers(db);
});
describe('noteCollaborators table', () => {
it('should add a collaborator to a note', () => {
seedNote(db, 'n1', 1);
db.insert(noteCollaborators)
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
.run();
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
expect(result).toHaveLength(1);
expect(result[0].userId).toBe(2);
expect(result[0].addedBy).toBe(1);
});
it('should enforce unique constraint on (noteId, userId)', () => {
seedNote(db, 'n1', 1);
db.insert(noteCollaborators)
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
.run();
expect(() =>
db.insert(noteCollaborators)
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
.run()
).toThrow();
});
it('should allow multiple collaborators per note', () => {
seedNote(db, 'n1', 1);
// Add a third user
db.insert(users)
.values({ email: 'third@test.com', displayName: 'Third', role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
db.insert(noteCollaborators).values([
{ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() },
{ noteId: 'n1', userId: 3, addedBy: 1, addedAt: new Date() }
]).run();
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
expect(result).toHaveLength(2);
});
it('should remove a collaborator', () => {
seedNote(db, 'n1', 1);
db.insert(noteCollaborators)
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
.run();
db.delete(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, 'n1'), eq(noteCollaborators.userId, 2)))
.run();
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
expect(result).toHaveLength(0);
});
});
describe('noteUserState table', () => {
it('should store per-user pin/archive/sortOrder state', () => {
seedNote(db, 'n1', 1);
db.insert(noteUserState)
.values({ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 5 })
.run();
const result = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2)))
.get();
expect(result).toBeDefined();
expect(result!.pinned).toBe(true);
expect(result!.archived).toBe(false);
expect(result!.sortOrder).toBe(5);
});
it('should enforce unique constraint on (noteId, userId)', () => {
seedNote(db, 'n1', 1);
db.insert(noteUserState)
.values({ noteId: 'n1', userId: 2, pinned: false, archived: false, sortOrder: 0 })
.run();
expect(() =>
db.insert(noteUserState)
.values({ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 0 })
.run()
).toThrow();
});
it('should default to false/false/0 for new state', () => {
seedNote(db, 'n1', 1);
db.insert(noteUserState)
.values({ noteId: 'n1', userId: 2 })
.run();
const result = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2)))
.get();
expect(result!.pinned).toBe(false);
expect(result!.archived).toBe(false);
expect(result!.sortOrder).toBe(0);
});
it('should allow independent state per user per note', () => {
seedNote(db, 'n1', 1);
db.insert(users)
.values({ email: 'third@test.com', displayName: 'Third', role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
db.insert(noteUserState).values([
{ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 1 },
{ noteId: 'n1', userId: 3, pinned: false, archived: true, sortOrder: 2 }
]).run();
const user2State = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2))).get();
const user3State = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 3))).get();
expect(user2State!.pinned).toBe(true);
expect(user2State!.archived).toBe(false);
expect(user3State!.pinned).toBe(false);
expect(user3State!.archived).toBe(true);
});
});
+42
View File
@@ -93,6 +93,21 @@ sqlite.exec(`
client_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS note_collaborators (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
added_by INTEGER NOT NULL REFERENCES users(id),
added_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS note_user_state (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
@@ -168,6 +183,10 @@ sqlite.exec(`
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
CREATE INDEX IF NOT EXISTS login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
CREATE UNIQUE INDEX IF NOT EXISTS note_collaborators_unique ON note_collaborators(note_id, user_id);
CREATE INDEX IF NOT EXISTS note_collaborators_user_id_idx ON note_collaborators(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS note_user_state_unique ON note_user_state(note_id, user_id);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
@@ -201,6 +220,29 @@ try {
// Column already exists
}
// Migration: make password_hash nullable (for OAuth-only users)
const pwCol = (
sqlite.prepare("PRAGMA table_info('users')").all() as { name: string; notnull: number }[]
).find((col) => col.name === 'password_hash');
if (pwCol && pwCol.notnull) {
sqlite.exec(`
CREATE TABLE __new_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
INSERT INTO __new_users(id, email, display_name, role, password_hash, auth_provider, provider_id, created_at)
SELECT id, email, display_name, role, password_hash, auth_provider, provider_id, created_at FROM users;
DROP TABLE users;
ALTER TABLE __new_users RENAME TO users;
`);
}
// Re-enable foreign keys
sqlite.pragma('foreign_keys = ON');
+36
View File
@@ -128,6 +128,42 @@ export const syncLog = sqliteTable(
(table) => [index('sync_log_timestamp_idx').on(table.timestamp)]
);
export const noteCollaborators = sqliteTable(
'note_collaborators',
{
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
addedBy: integer('added_by')
.references(() => users.id)
.notNull(),
addedAt: integer('added_at', { mode: 'timestamp' }).notNull()
},
(table) => [
uniqueIndex('note_collaborators_unique').on(table.noteId, table.userId),
index('note_collaborators_user_id_idx').on(table.userId)
]
);
export const noteUserState = sqliteTable(
'note_user_state',
{
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
sortOrder: integer('sort_order').default(0).notNull()
},
(table) => [uniqueIndex('note_user_state_unique').on(table.noteId, table.userId)]
);
export const loginAttempts = sqliteTable(
'login_attempts',
{
+18
View File
@@ -68,6 +68,24 @@ export function createTestDb(options?: { seedUser?: boolean }) {
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
CREATE TABLE note_collaborators (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
added_by INTEGER NOT NULL REFERENCES users(id),
added_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX note_collaborators_unique ON note_collaborators(note_id, user_id);
CREATE INDEX note_collaborators_user_id_idx ON note_collaborators(user_id);
CREATE TABLE note_user_state (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX note_user_state_unique ON note_user_state(note_id, user_id);
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
+263 -73
View File
@@ -1,59 +1,158 @@
import { db as defaultDb } from './db/index.js';
import { notes, noteTags, tags } from './db/schema.js';
import { eq, and, desc, like, or, inArray } from 'drizzle-orm';
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
import { eq, and, desc, like, or, inArray, sql } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
import { fetchAttachmentsForNotes } from './attachments.js';
import { fetchCollaboratorsForNotes } from './collaborators.js';
import { canAccessNote } from './api-utils.js';
import type { NoteFilter } from '$lib/types/index.js';
const db = defaultDb;
interface NoteRow {
id: string;
userId: number;
title: string;
content: string;
color: string;
pinned: boolean;
archived: boolean;
trashed: boolean;
trashedAt: Date | null;
checklistMode: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
version: number;
}
function hydrateNotes(noteRows: NoteRow[], userId: number) {
const noteIds = noteRows.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
const attachmentMap = fetchAttachmentsForNotes(noteIds);
const collabMap = fetchCollaboratorsForNotes(noteIds);
return noteRows.map((note) => {
const collaborators = collabMap.get(note.id) ?? [];
const isOwner = note.userId === userId;
const isShared = collaborators.length > 0;
return {
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? [],
collaborators,
isOwner,
isShared
};
});
}
/**
* Get shared notes for a user with per-user state overlay.
*/
function getSharedNotes(userId: number, filter: 'all' | 'archived'): NoteRow[] {
const rows = db
.select({
id: notes.id,
userId: notes.userId,
title: notes.title,
content: notes.content,
color: notes.color,
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
trashed: notes.trashed,
trashedAt: notes.trashedAt,
checklistMode: notes.checklistMode,
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
createdAt: notes.createdAt,
updatedAt: notes.updatedAt,
version: notes.version
})
.from(noteCollaborators)
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
.leftJoin(
noteUserState,
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
)
.where(
and(
eq(noteCollaborators.userId, userId),
eq(notes.trashed, false),
filter === 'archived'
? sql`COALESCE(${noteUserState.archived}, 0) = 1`
: sql`COALESCE(${noteUserState.archived}, 0) = 0`
)
)
.all();
return rows as NoteRow[];
}
export function listNotes(userId: number, filter: NoteFilter = 'all') {
// Owned notes
let conditions;
switch (filter) {
case 'archived':
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
break;
case 'trashed':
// Collaborators never see trashed notes — only owner's trashed notes
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
break;
default:
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
}
const result = db
const ownedNotes = db
.select()
.from(notes)
.where(conditions)
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
.all();
.all() as NoteRow[];
const noteIds = result.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
const attachmentMap = fetchAttachmentsForNotes(noteIds);
return result.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
}));
// Shared notes (not for trashed filter — collaborators don't see trash)
let sharedNotes: NoteRow[] = [];
if (filter !== 'trashed') {
sharedNotes = getSharedNotes(userId, filter === 'archived' ? 'archived' : 'all');
}
const combined = [...ownedNotes, ...sharedNotes];
combined.sort((a, b) => {
// Pinned first, then by updatedAt descending
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return b.updatedAt.getTime() - a.updatedAt.getTime();
});
return hydrateNotes(combined, userId);
}
export function getNote(userId: number, id: string) {
const note = db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.get();
const { canAccess, isOwner } = canAccessNote(id, userId);
if (!canAccess) return null;
const note = db.select().from(notes).where(eq(notes.id, id)).get();
if (!note) return null;
const tagMap = fetchTagsForNotes([id]);
const attachmentMap = fetchAttachmentsForNotes([id]);
return {
...note,
tags: tagMap.get(id) ?? [],
attachments: attachmentMap.get(id) ?? []
};
let effectiveNote = note as NoteRow;
// For collaborators, overlay per-user state
if (!isOwner) {
const userState = db
.select()
.from(noteUserState)
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.get();
effectiveNote = {
...note,
pinned: userState?.pinned ?? false,
archived: userState?.archived ?? false,
sortOrder: userState?.sortOrder ?? 0
};
}
const result = hydrateNotes([effectiveNote], userId);
return result[0] ?? null;
}
export interface CreateNoteInput {
@@ -109,56 +208,94 @@ export interface UpdateNoteInput {
sortOrder?: number;
}
/** Per-user fields that go to noteUserState for collaborators */
const PER_USER_FIELDS = ['pinned', 'archived', 'sortOrder'] as const;
export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
const existing = db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.get();
const { canAccess, isOwner } = canAccessNote(id, userId);
if (!canAccess) return null;
const existing = db.select().from(notes).where(eq(notes.id, id)).get();
if (!existing) return null;
// Collaborators cannot trash notes
if (!isOwner && input.trashed !== undefined) {
return null;
}
const now = new Date();
const updates: Record<string, unknown> = {
// Shared fields: update the notes table (all participants can edit content)
const sharedUpdates: Record<string, unknown> = {
updatedAt: now,
version: existing.version + 1
};
let hasSharedUpdates = false;
if (input.title !== undefined) updates.title = input.title;
if (input.content !== undefined) updates.content = input.content;
if (input.color !== undefined) updates.color = input.color;
if (input.pinned !== undefined) updates.pinned = input.pinned;
if (input.archived !== undefined) updates.archived = input.archived;
if (input.trashed !== undefined) {
updates.trashed = input.trashed;
updates.trashedAt = input.trashed ? now : null;
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
if (input.content !== undefined) { sharedUpdates.content = input.content; hasSharedUpdates = true; }
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
if (input.checklistMode !== undefined) { sharedUpdates.checklistMode = input.checklistMode; hasSharedUpdates = true; }
if (isOwner) {
// Owner: per-user fields go to notes table directly
if (input.pinned !== undefined) { sharedUpdates.pinned = input.pinned; hasSharedUpdates = true; }
if (input.archived !== undefined) { sharedUpdates.archived = input.archived; hasSharedUpdates = true; }
if (input.trashed !== undefined) {
sharedUpdates.trashed = input.trashed;
sharedUpdates.trashedAt = input.trashed ? now : null;
hasSharedUpdates = true;
}
if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; }
}
if (input.checklistMode !== undefined) updates.checklistMode = input.checklistMode;
if (input.sortOrder !== undefined) updates.sortOrder = input.sortOrder;
db.update(notes)
.set(updates)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.run();
if (hasSharedUpdates || isOwner) {
db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run();
}
const updated = db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.get();
// Collaborator: per-user fields go to noteUserState
if (!isOwner) {
const hasPerUserFields = PER_USER_FIELDS.some((f) => input[f] !== undefined);
if (hasPerUserFields) {
const userState = db
.select()
.from(noteUserState)
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.get();
if (userState) {
const stateUpdates: Record<string, unknown> = {};
if (input.pinned !== undefined) stateUpdates.pinned = input.pinned;
if (input.archived !== undefined) stateUpdates.archived = input.archived;
if (input.sortOrder !== undefined) stateUpdates.sortOrder = input.sortOrder;
db.update(noteUserState)
.set(stateUpdates)
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.run();
} else {
db.insert(noteUserState)
.values({
noteId: id,
userId,
pinned: input.pinned ?? false,
archived: input.archived ?? false,
sortOrder: input.sortOrder ?? 0
})
.run();
}
}
}
if (input.title !== undefined || input.content !== undefined) {
const content = `${updated!.title} ${updated!.content}`;
const extractedTags = extractTags(content);
syncNoteTags(id, extractedTags, userId);
const updated = db.select().from(notes).where(eq(notes.id, id)).get();
if (updated) {
const content = `${updated.title} ${updated.content}`;
const extractedTags = extractTags(content);
syncNoteTags(id, extractedTags, existing.userId);
}
}
const tagMap = fetchTagsForNotes([id]);
const attachmentMap = fetchAttachmentsForNotes([id]);
return {
...updated,
tags: tagMap.get(id) ?? [],
attachments: attachmentMap.get(id) ?? []
};
return getNote(userId, id);
}
export function deleteNote(userId: number, id: string): boolean {
@@ -180,7 +317,8 @@ export function searchNotes(userId: number, query: string) {
const pattern = `%${query}%`;
const results = db
// Owned notes matching query
const ownedResults = db
.select()
.from(notes)
.where(
@@ -192,6 +330,40 @@ export function searchNotes(userId: number, query: string) {
)
.all();
// Shared notes matching query
const sharedResults = db
.select({
id: notes.id,
userId: notes.userId,
title: notes.title,
content: notes.content,
color: notes.color,
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
trashed: notes.trashed,
trashedAt: notes.trashedAt,
checklistMode: notes.checklistMode,
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
createdAt: notes.createdAt,
updatedAt: notes.updatedAt,
version: notes.version
})
.from(noteCollaborators)
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
.leftJoin(
noteUserState,
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
)
.where(
and(
eq(noteCollaborators.userId, userId),
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
)
.all();
// Tag search for owned notes
const tagResults = db
.select({ noteId: noteTags.noteId })
.from(noteTags)
@@ -199,12 +371,12 @@ export function searchNotes(userId: number, query: string) {
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
.all();
const resultIds = new Set(results.map((n) => n.id));
const resultIds = new Set([...ownedResults.map((n) => n.id), ...sharedResults.map((n) => n.id)]);
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
(id) => !resultIds.has(id)
);
let extraNotes: typeof results = [];
let extraNotes: typeof ownedResults = [];
if (extraNoteIds.length > 0) {
extraNotes = db
.select()
@@ -213,12 +385,8 @@ export function searchNotes(userId: number, query: string) {
.all();
}
const combined = [...results, ...extraNotes];
const tagMap = fetchTagsForNotes(combined.map((n) => n.id));
return combined.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? []
}));
const combined = [...ownedResults, ...sharedResults, ...extraNotes] as NoteRow[];
return hydrateNotes(combined, userId);
}
export function listAllTags(userId: number) {
@@ -228,9 +396,31 @@ export function listAllTags(userId: number) {
export function reorderNotes(userId: number, orders: { id: string; sortOrder: number }[]) {
const now = new Date();
for (const { id, sortOrder } of orders) {
db.update(notes)
.set({ sortOrder, updatedAt: now })
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.run();
const { canAccess, isOwner } = canAccessNote(id, userId);
if (!canAccess) continue;
if (isOwner) {
db.update(notes)
.set({ sortOrder, updatedAt: now })
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.run();
} else {
// Collaborator: upsert into noteUserState
const existing = db
.select()
.from(noteUserState)
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.get();
if (existing) {
db.update(noteUserState)
.set({ sortOrder })
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.run();
} else {
db.insert(noteUserState)
.values({ noteId: id, userId, pinned: false, archived: false, sortOrder })
.run();
}
}
}
}
+15
View File
@@ -233,6 +233,21 @@ export async function togglePin(id: string, currentPinned: boolean): Promise<Not
return updateNote(id, { pinned: !currentPinned });
}
export async function leaveNote(id: string): Promise<boolean> {
try {
const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
return true;
}
showToast('Failed to leave note', 'error');
return false;
} catch {
return false;
}
}
export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise<boolean> {
// Optimistic update
notes.update((list) =>
+294
View File
@@ -0,0 +1,294 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from '../server/db/test-helpers.js';
import { notes, noteCollaborators, noteUserState, users, syncLog } from '../server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from '../server/db/schema.js';
import { getChangesSince, processSyncPush } from './server.js';
let db: BetterSQLite3Database<typeof schema>;
const OWNER_ID = 1;
const COLLAB_ID = 2;
function seedUsers() {
db.insert(users).values([
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
{ email: 'collab@test.com', displayName: 'Collaborator', role: 'user', authProvider: 'password', createdAt: new Date() }
]).run();
}
function seedSharedNote(id: string, overrides: Partial<typeof notes.$inferInsert> = {}) {
const now = new Date();
db.insert(notes).values({
id,
userId: OWNER_ID,
title: 'Shared Note',
content: 'Original content',
createdAt: now,
updatedAt: now,
...overrides
}).run();
db.insert(noteCollaborators).values({
noteId: id,
userId: COLLAB_ID,
addedBy: OWNER_ID,
addedAt: now
}).run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUsers();
});
describe('getChangesSince — shared notes', () => {
it('should return shared notes updated after the given timestamp', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
seedSharedNote('n1', { updatedAt: t2, createdAt: t1 });
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
expect(changes).toHaveLength(1);
expect(changes[0].id).toBe('n1');
expect(changes[0].title).toBe('Shared Note');
expect(changes[0].content).toBe('Original content');
});
it('should not return shared notes updated before the given timestamp', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const changes = await getChangesSince(t2.getTime(), COLLAB_ID, db);
expect(changes).toHaveLength(0);
});
it('should return per-user state overlay for collaborators', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
// Owner has pinned=true, archived=false on the note
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, pinned: true, archived: false });
// Collaborator has pinned=false, archived=true in noteUserState
db.insert(noteUserState).values({
noteId: 'n1',
userId: COLLAB_ID,
pinned: false,
archived: true,
sortOrder: 5
}).run();
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
expect(changes).toHaveLength(1);
// Should reflect the collaborator's per-user state, not the owner's
expect(changes[0].pinned).toBeFalsy();
expect(changes[0].archived).toBeTruthy();
expect(changes[0].sortOrder).toBe(5);
});
it('should default to false/0 when collaborator has no noteUserState', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
// Owner has pinned=true, sortOrder=10
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, pinned: true, sortOrder: 10 });
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
expect(changes).toHaveLength(1);
// Without noteUserState, collaborator should get defaults (false/0), not owner's values
expect(changes[0].pinned).toBeFalsy();
expect(changes[0].sortOrder).toBe(0);
});
it('should not return trashed notes for collaborators', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, trashed: true });
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
expect(changes).toHaveLength(0);
});
it('should return owner edits to a shared note for the collaborator', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
// Simulate owner editing the note
const t2 = new Date('2024-01-02T00:00:00Z');
db.update(notes)
.set({ content: 'Updated by owner', updatedAt: t2, version: 2 })
.where(eq(notes.id, 'n1'))
.run();
// Collaborator syncs since t1
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
expect(changes).toHaveLength(1);
expect(changes[0].content).toBe('Updated by owner');
expect(changes[0].version).toBe(2);
});
it('should return both owned and shared notes for a user who is both owner and collaborator on different notes', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
// Note owned by COLLAB_ID
db.insert(notes).values({
id: 'owned-by-collab',
userId: COLLAB_ID,
title: 'My Own Note',
content: '',
createdAt: t1,
updatedAt: t2
}).run();
// Note shared with COLLAB_ID (owned by OWNER_ID)
seedSharedNote('shared-with-collab', { updatedAt: t2, createdAt: t1 });
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
expect(changes).toHaveLength(2);
const ids = changes.map((c) => c.id).sort();
expect(ids).toEqual(['owned-by-collab', 'shared-with-collab']);
});
});
describe('processSyncPush — shared notes', () => {
it('should allow collaborator to update shared content fields', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const t2 = t1.getTime() + 1000;
await processSyncPush([{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { title: 'Updated by collab', content: 'New content' }
}], COLLAB_ID, db);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.title).toBe('Updated by collab');
expect(note.content).toBe('New content');
expect(note.version).toBe(2);
});
it('should route collaborator per-user fields to noteUserState', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const t2 = t1.getTime() + 1000;
await processSyncPush([{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { pinned: true, sortOrder: 3 }
}], COLLAB_ID, db);
// Per-user fields should be in noteUserState, not in notes
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.pinned).toBe(false); // Owner's pinned state unchanged
const userState = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, COLLAB_ID)))
.get();
expect(userState).toBeDefined();
expect(userState!.pinned).toBe(true);
expect(userState!.sortOrder).toBe(3);
});
it('should not allow collaborator to trash a note', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const t2 = t1.getTime() + 1000;
await processSyncPush([{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { trashed: true }
}], COLLAB_ID, db);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.trashed).toBe(false);
});
it('should reject changes with older timestamp than server note', async () => {
const t2 = new Date('2024-01-02T00:00:00Z');
seedSharedNote('n1', { updatedAt: t2, createdAt: new Date('2024-01-01T00:00:00Z') });
// Collaborator's change has a timestamp BEFORE the server's updatedAt
const t1 = new Date('2024-01-01T12:00:00Z').getTime();
await processSyncPush([{
noteId: 'n1',
operation: 'update',
timestamp: t1,
data: { title: 'Stale edit' }
}], COLLAB_ID, db);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.title).toBe('Shared Note'); // Unchanged
});
it('should not allow non-collaborator to update the note', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
// Create note without sharing with user 3
db.insert(notes).values({
id: 'n1',
userId: OWNER_ID,
title: 'Private',
content: '',
createdAt: t1,
updatedAt: t1
}).run();
// Add a third user
db.insert(users).values({
email: 'stranger@test.com',
displayName: 'Stranger',
role: 'user',
authProvider: 'password',
createdAt: new Date()
}).run();
const t2 = t1.getTime() + 1000;
await processSyncPush([{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { title: 'Hacked' }
}], 3, db); // User 3 is not owner or collaborator
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.title).toBe('Private'); // Unchanged
});
it('should log sync operations', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const t2 = t1.getTime() + 1000;
await processSyncPush([{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { title: 'Updated' }
}], COLLAB_ID, db);
const logs = db.select().from(syncLog).where(eq(syncLog.noteId, 'n1')).all();
expect(logs).toHaveLength(1);
expect(logs[0].userId).toBe(COLLAB_ID);
expect(logs[0].operation).toBe('update');
});
});
+123 -22
View File
@@ -1,14 +1,18 @@
import { db } from '$lib/server/db/index.js';
import { notes, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and } from 'drizzle-orm';
import { db as defaultDb } from '$lib/server/db/index.js';
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, or, sql } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { syncNoteTags } from '$lib/server/tags.js';
import { canAccessNote } from '$lib/server/api-utils.js';
import type { SyncQueueItem } from './idb.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyDb = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<any>;
/**
* Process incoming sync changes from client.
*/
export async function processSyncPush(changes: SyncQueueItem[], userId: number): Promise<void> {
export async function processSyncPush(changes: SyncQueueItem[], userId: number, injectedDb?: AnyDb): Promise<void> {
const db = injectedDb ?? defaultDb;
const noteIdsToSyncTags: string[] = [];
db.transaction((tx) => {
@@ -17,24 +21,78 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
case 'create':
case 'update': {
if (!change.data) continue;
const existing = tx
.select()
.from(notes)
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
.get();
if (existing) {
if (change.timestamp > existing.updatedAt.getTime()) {
// Check access: owner can update all fields, collaborator can update shared fields
const note = tx.select().from(notes).where(eq(notes.id, change.noteId)).get();
if (note) {
const isOwner = note.userId === userId;
const isCollaborator = !isOwner && tx
.select({ userId: noteCollaborators.userId })
.from(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, change.noteId), eq(noteCollaborators.userId, userId)))
.get();
if (!isOwner && !isCollaborator) continue;
if (change.timestamp > note.updatedAt.getTime()) {
// Shared fields: title, content, color, checklistMode
const sharedData: Record<string, unknown> = {
updatedAt: new Date(change.timestamp),
version: note.version + 1
};
if (change.data.title !== undefined) sharedData.title = change.data.title;
if (change.data.content !== undefined) sharedData.content = change.data.content;
if (change.data.color !== undefined) sharedData.color = change.data.color;
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
if (isOwner) {
// Owner: per-user fields go to notes table
if (change.data.pinned !== undefined) sharedData.pinned = change.data.pinned;
if (change.data.archived !== undefined) sharedData.archived = change.data.archived;
if (change.data.trashed !== undefined) {
sharedData.trashed = change.data.trashed;
sharedData.trashedAt = change.data.trashed ? new Date(change.timestamp) : null;
}
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
}
tx.update(notes)
.set({
...change.data,
updatedAt: new Date(change.timestamp),
version: existing.version + 1
})
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
.set(sharedData)
.where(eq(notes.id, change.noteId))
.run();
// Collaborator: per-user fields go to noteUserState
if (!isOwner) {
const hasPerUser = change.data.pinned !== undefined ||
change.data.archived !== undefined ||
change.data.sortOrder !== undefined;
if (hasPerUser) {
const existing = tx.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
.get();
if (existing) {
const updates: Record<string, unknown> = {};
if (change.data.pinned !== undefined) updates.pinned = change.data.pinned;
if (change.data.archived !== undefined) updates.archived = change.data.archived;
if (change.data.sortOrder !== undefined) updates.sortOrder = change.data.sortOrder;
tx.update(noteUserState).set(updates)
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
.run();
} else {
tx.insert(noteUserState).values({
noteId: change.noteId,
userId,
pinned: change.data.pinned ?? false,
archived: change.data.archived ?? false,
sortOrder: change.data.sortOrder ?? 0
}).run();
}
}
}
}
} else if (change.operation === 'create' && change.data) {
// Only owner can create notes
tx.insert(notes)
.values({
id: change.noteId,
@@ -60,6 +118,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
break;
}
case 'delete': {
// Only owner can delete
tx.delete(notes).where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))).run();
break;
}
@@ -78,21 +137,63 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
});
for (const noteId of noteIdsToSyncTags) {
const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get();
const note = db.select().from(notes).where(eq(notes.id, noteId)).get();
if (note) {
const content = `${note.title} ${note.content}`;
syncNoteTags(noteId, extractTags(content), userId);
syncNoteTags(noteId, extractTags(content), note.userId);
}
}
}
/**
* Get all notes updated since a given timestamp for a specific user.
* Includes both owned and shared notes.
* For shared notes, per-user state (pinned, archived, sortOrder) is overlaid
* from noteUserState so collaborators get their own view.
*/
export async function getChangesSince(sinceTimestamp: number, userId: number) {
return db
export async function getChangesSince(sinceTimestamp: number, userId: number, injectedDb?: AnyDb) {
const db = injectedDb ?? defaultDb;
const since = new Date(sinceTimestamp);
// Owned notes
const ownedNotes = db
.select()
.from(notes)
.where(and(eq(notes.userId, userId), gt(notes.updatedAt, new Date(sinceTimestamp))))
.where(and(eq(notes.userId, userId), gt(notes.updatedAt, since)))
.all();
// Shared notes (via collaborator relationship) with per-user state overlay
const sharedNotes = db
.select({
id: notes.id,
userId: notes.userId,
title: notes.title,
content: notes.content,
color: notes.color,
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
trashed: notes.trashed,
trashedAt: notes.trashedAt,
checklistMode: notes.checklistMode,
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
createdAt: notes.createdAt,
updatedAt: notes.updatedAt,
version: notes.version
})
.from(noteCollaborators)
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
.leftJoin(
noteUserState,
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
)
.where(
and(
eq(noteCollaborators.userId, userId),
eq(notes.trashed, false),
gt(notes.updatedAt, since)
)
)
.all();
return [...ownedNotes, ...sharedNotes];
}
+10
View File
@@ -14,6 +14,16 @@ export interface Note {
version: number;
tags?: string[];
attachments?: Attachment[];
collaborators?: Collaborator[];
isOwner?: boolean;
isShared?: boolean;
}
export interface Collaborator {
userId: number;
displayName: string;
email: string;
addedAt: Date;
}
export interface NoteCreate {
+7 -67
View File
@@ -1,79 +1,19 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes } from '$lib/server/db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
import { getUserId } from '$lib/server/api-utils.js';
import { listNotes, createNote } from '$lib/server/notes-service.js';
import type { NoteFilter } from '$lib/types/index.js';
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
const filter = url.searchParams.get('filter') || 'all';
let conditions;
switch (filter) {
case 'archived':
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
break;
case 'trashed':
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
break;
default:
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
}
const result = db
.select()
.from(notes)
.where(conditions)
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
.all();
const noteIds = result.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
const attachmentMap = fetchAttachmentsForNotes(noteIds);
const notesWithTags = result.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
}));
return json(notesWithTags);
const filter = (url.searchParams.get('filter') || 'all') as NoteFilter;
const result = listNotes(userId, filter);
return json(result);
};
export const POST: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const body = await request.json();
const now = new Date();
const id = body.id || uuidv4();
const newNote = {
id,
userId,
title: body.title || '',
content: body.content || '',
color: body.color || 'default',
pinned: body.pinned || false,
archived: false,
trashed: false,
trashedAt: null,
checklistMode: body.checklistMode || false,
sortOrder: body.sortOrder || 0,
createdAt: now,
updatedAt: now,
version: 1
};
const content = `${newNote.title} ${newNote.content}`;
const extractedTags = extractTags(content);
db.transaction((tx) => {
tx.insert(notes).values(newNote).run();
});
syncNoteTags(id, extractedTags, userId);
return json({ ...newNote, tags: extractedTags }, { status: 201 });
const newNote = createNote(userId, body);
return json(newNote, { status: 201 });
};
+11 -78
View File
@@ -1,98 +1,31 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
import { getUserId } from '$lib/server/api-utils.js';
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js';
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
const note = db
.select()
.from(notes)
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
.get();
const note = getNote(userId, params.id);
if (!note) throw error(404, 'Note not found');
if (!note) {
throw error(404, 'Note not found');
}
const tagMap = fetchTagsForNotes([note.id]);
const attachmentMap = fetchAttachmentsForNotes([note.id]);
return json({
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
});
return json(note);
};
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
const body = await request.json();
const existing = db
.select()
.from(notes)
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
.get();
const updated = updateNote(userId, params.id, body);
if (!updated) throw error(404, 'Note not found');
if (!existing) {
throw error(404, 'Note not found');
}
const now = new Date();
const updates: Record<string, unknown> = {
updatedAt: now,
version: existing.version + 1
};
if (body.title !== undefined) updates.title = body.title;
if (body.content !== undefined) updates.content = body.content;
if (body.color !== undefined) updates.color = body.color;
if (body.pinned !== undefined) updates.pinned = body.pinned;
if (body.archived !== undefined) updates.archived = body.archived;
if (body.trashed !== undefined) {
updates.trashed = body.trashed;
updates.trashedAt = body.trashed ? now : null;
}
if (body.checklistMode !== undefined) updates.checklistMode = body.checklistMode;
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
db.update(notes).set(updates).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run();
const updated = db.select().from(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).get();
if (body.title !== undefined || body.content !== undefined) {
const content = `${updated!.title} ${updated!.content}`;
const extractedTags = extractTags(content);
syncNoteTags(params.id, extractedTags, userId);
}
const tagMap = fetchTagsForNotes([params.id]);
const attachmentMap = fetchAttachmentsForNotes([params.id]);
return json({
...updated,
tags: tagMap.get(params.id) ?? [],
attachments: attachmentMap.get(params.id) ?? []
});
return json(updated);
};
export const DELETE: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
const existing = db
.select()
.from(notes)
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
.get();
requireNoteOwnership(params.id, userId);
if (!existing) {
throw error(404, 'Note not found');
}
const deleted = deleteNote(userId, params.id);
if (!deleted) throw error(404, 'Note not found');
db.delete(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run();
return json({ success: true });
};
@@ -2,25 +2,11 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment, updateAttachment } from '$lib/server/attachments.js';
import { readFile } from 'fs/promises';
import { db } from '$lib/server/db/index.js';
import { notes } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import { getUserId } from '$lib/server/api-utils.js';
/** Verify the note belongs to the authenticated user */
async function verifyNoteOwnership(noteId: string, userId: number) {
const note = await db
.select()
.from(notes)
.where(and(eq(notes.id, noteId), eq(notes.userId, userId)))
.get();
if (!note) throw error(404, 'Note not found');
return note;
}
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
export const GET: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
requireNoteAccess(params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
@@ -48,7 +34,7 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
export const POST: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
requireNoteAccess(params.id, userId);
const formData = await request.formData();
const file = formData.get('file') as File;
@@ -73,7 +59,7 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
export const PATCH: RequestHandler = async ({ params, url, request, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
requireNoteAccess(params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
@@ -89,7 +75,7 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
requireNoteAccess(params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
@@ -0,0 +1,73 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from '$lib/server/collaborators.js';
import { db } from '$lib/server/db/index.js';
import { users, notes, noteCollaborators } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(params.id, userId);
const collaborators = fetchCollaboratorsForNotes([params.id]);
return json(collaborators.get(params.id) ?? []);
};
export const POST: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
requireNoteOwnership(params.id, userId);
const body = await request.json();
const targetUserId = body.userId;
if (!targetUserId || typeof targetUserId !== 'number') {
throw error(400, 'userId is required');
}
// Cannot add yourself
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, params.id)).get();
if (note && note.userId === targetUserId) {
throw error(400, 'Cannot add the owner as a collaborator');
}
// Target user must exist
const targetUser = db.select({ id: users.id }).from(users).where(eq(users.id, targetUserId)).get();
if (!targetUser) {
throw error(404, 'User not found');
}
// Check not already a collaborator
const existing = db
.select({ userId: noteCollaborators.userId })
.from(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, params.id), eq(noteCollaborators.userId, targetUserId)))
.get();
if (existing) {
throw error(409, 'User is already a collaborator');
}
addCollaborator(params.id, targetUserId, userId);
const collaborators = fetchCollaboratorsForNotes([params.id]);
return json(collaborators.get(params.id) ?? [], { status: 201 });
};
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
const userIdParam = url.searchParams.get('userId');
const targetUserId = userIdParam === 'self' ? userId : Number(userIdParam);
if (!targetUserId) {
throw error(400, 'userId query parameter is required');
}
const { isOwner } = requireNoteAccess(params.id, userId);
// Owner can remove anyone. Collaborator can only remove self.
if (!isOwner && targetUserId !== userId) {
throw error(403, 'Only the owner can remove other collaborators');
}
removeCollaborator(params.id, targetUserId);
return json({ success: true });
};
+3 -48
View File
@@ -1,10 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
import { and, eq, like, or, inArray } from 'drizzle-orm';
import { fetchTagsForNotes } from '$lib/server/tags.js';
import { getUserId } from '$lib/server/api-utils.js';
import { searchNotes } from '$lib/server/notes-service.js';
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
@@ -14,48 +11,6 @@ export const GET: RequestHandler = async ({ url, ...event }) => {
return json([]);
}
const pattern = `%${query}%`;
const results = db
.select()
.from(notes)
.where(
and(
eq(notes.userId, userId),
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
)
.all();
// Also search by tag name
const tagResults = db
.select({ noteId: noteTags.noteId })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
.all();
const resultIds = new Set(results.map((n) => n.id));
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
(id) => !resultIds.has(id)
);
let extraNotes: typeof results = [];
if (extraNoteIds.length > 0) {
extraNotes = db
.select()
.from(notes)
.where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
.all();
}
const combined = [...results, ...extraNotes];
const tagMap = fetchTagsForNotes(combined.map((n) => n.id));
const withTags = combined.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? []
}));
return json(withTags);
const results = searchNotes(userId, query);
return json(results);
};
+35
View File
@@ -0,0 +1,35 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { db } from '$lib/server/db/index.js';
import { users } from '$lib/server/db/schema.js';
import { ne } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
const query = url.searchParams.get('q')?.trim();
if (!query || query.length < 1) {
return json([]);
}
const pattern = `%${query}%`;
const results = db
.select({
id: users.id,
displayName: users.displayName,
email: users.email
})
.from(users)
.where(
ne(users.id, userId)
)
.all()
.filter(
(u) =>
u.displayName.toLowerCase().includes(query.toLowerCase()) ||
u.email.toLowerCase().includes(query.toLowerCase())
)
.slice(0, 10);
return json(results);
};
+233
View File
@@ -0,0 +1,233 @@
import { test, expect, noteCard, TEST_EMAIL, TEST_PASSWORD } from './helpers/fixtures.js';
import type { Page, BrowserContext } from '@playwright/test';
const COLLAB_EMAIL = 'collab@test.com';
const COLLAB_PASSWORD = 'collabpass123';
const COLLAB_NAME = 'Collaborator User';
/**
* Create a second user via the admin API (using the admin's session cookies).
*/
async function createCollaboratorUser(adminPage: Page) {
const cookies = await adminPage.context().cookies();
const sessionCookie = cookies.find((c) => c.name === 'session');
if (!sessionCookie) throw new Error('No session cookie found');
const res = await adminPage.request.post('/api/admin/users', {
data: {
email: COLLAB_EMAIL,
displayName: COLLAB_NAME,
password: COLLAB_PASSWORD,
role: 'user'
}
});
if (!res.ok()) {
const body = await res.text();
// User may already exist from prior test
if (!body.includes('already exists') && res.status() !== 409) {
throw new Error(`Failed to create collaborator user: ${res.status()} ${body}`);
}
}
}
/**
* Log in as the collaborator user in a new browser context.
*/
async function loginAsCollaborator(browser: import('@playwright/test').Browser): Promise<{ context: BrowserContext; page: Page }> {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByTestId('email-input').fill(COLLAB_EMAIL);
await page.getByTestId('password-input').fill(COLLAB_PASSWORD);
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
await page.waitForLoadState('networkidle');
return { context, page };
}
test.describe.serial('Note Sharing', () => {
let collabContext: BrowserContext;
let collabPage: Page;
test('Scenario: Owner shares a note with another user', async ({ authenticatedPage: page, browser }) => {
// Given a second user exists
await createCollaboratorUser(page);
// When the owner creates a note titled "Shared Note"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Shared Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('Shared content');
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Shared Note')).toBeVisible();
// And the owner opens the share dialog
await noteCard(page, 'Shared Note').click();
await page.getByTestId('share-toggle').click();
await expect(page.getByTestId('share-dialog')).toBeVisible();
// And the owner searches for the collaborator
await page.getByTestId('share-search-input').fill('collab');
await expect(page.getByTestId('share-user-result').first()).toBeVisible({ timeout: 5000 });
// And the owner adds the collaborator
await page.getByTestId('share-user-result').first().click();
// Then the collaborator appears in the share dialog
await expect(page.getByTestId('share-collaborator')).toBeVisible();
// Close dialogs
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
await page.getByTestId('close-editor-btn').click();
// Then the note shows a collaborator indicator
const card = noteCard(page, 'Shared Note');
await expect(card.getByTestId('collaborator-indicator')).toBeVisible();
});
test('Scenario: Collaborator sees the shared note in their list', async ({ authenticatedPage: page, browser }) => {
// Given the note was shared in the previous test
// When the collaborator logs in
const collab = await loginAsCollaborator(browser);
collabContext = collab.context;
collabPage = collab.page;
// Then the collaborator sees "Shared Note" in their notes list
await expect(noteCard(collabPage, 'Shared Note')).toBeVisible({ timeout: 10000 });
// And the note shows a collaborator indicator
await expect(noteCard(collabPage, 'Shared Note').getByTestId('collaborator-indicator')).toBeVisible();
});
test('Scenario: Collaborator can edit the shared note content', async ({ authenticatedPage: page }) => {
// When the collaborator edits the note
await noteCard(collabPage, 'Shared Note').click();
const editor = collabPage.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially(' — edited by collaborator');
await collabPage.getByTestId('close-editor-btn').click();
// And the owner reloads
await page.reload();
await page.waitForLoadState('networkidle');
// Then the owner sees the collaborator's changes
await noteCard(page, 'Shared Note').click();
await expect(page.getByTestId('tiptap-editor')).toContainText('edited by collaborator');
await page.getByTestId('close-editor-btn').click();
});
test('Scenario: Owner and collaborator have independent pin state', async ({ authenticatedPage: page }) => {
// When the collaborator pins the shared note
const card = noteCard(collabPage, 'Shared Note');
await card.hover();
await card.getByTestId('pin-btn').click({ force: true });
// Then the collaborator sees the note as pinned
await expect(noteCard(collabPage, 'Shared Note').getByTestId('pin-indicator')).toBeVisible();
// But the owner does not see it as pinned
await page.reload();
await page.waitForLoadState('networkidle');
const ownerCard = noteCard(page, 'Shared Note');
await expect(ownerCard.getByTestId('pin-indicator')).not.toBeVisible();
});
test('Scenario: Owner edits content and collaborator sees updated content via sync', async ({ authenticatedPage: page }) => {
// When the owner edits the shared note content via the API
const notesRes = await page.request.get('/api/notes');
const allNotes = await notesRes.json();
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
expect(sharedNote).toBeDefined();
await page.request.patch(`/api/notes/${sharedNote.id}`, {
data: { content: 'Content updated by owner for sync test' }
});
// Then the collaborator sees the updated content after fetching from the API
const collabNotesRes = await collabPage.request.get('/api/notes');
const collabNotes = await collabNotesRes.json();
const collabSharedNote = collabNotes.find((n: { id: string }) => n.id === sharedNote.id);
expect(collabSharedNote).toBeDefined();
expect(collabSharedNote.content).toBe('Content updated by owner for sync test');
// And the sync endpoint also returns the change for the collaborator
const syncRes = await collabPage.request.get(`/api/sync?since=0`);
const syncedNotes = await syncRes.json();
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
expect(syncedNote).toBeDefined();
expect(syncedNote.content).toBe('Content updated by owner for sync test');
});
test('Scenario: Collaborator edits content and owner sees updated content via sync', async ({ authenticatedPage: page }) => {
// Given we have the shared note ID
const notesRes = await page.request.get('/api/notes');
const allNotes = await notesRes.json();
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
// When the collaborator edits the content via the API
await collabPage.request.patch(`/api/notes/${sharedNote.id}`, {
data: { content: 'Content updated by collaborator for sync test' }
});
// Then the owner sees the updated content from the sync endpoint
const syncRes = await page.request.get(`/api/sync?since=0`);
const syncedNotes = await syncRes.json();
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
expect(syncedNote).toBeDefined();
expect(syncedNote.content).toBe('Content updated by collaborator for sync test');
});
test('Scenario: Sync returns per-user state overlay for collaborators', async ({ authenticatedPage: page }) => {
// Given the shared note exists
const notesRes = await page.request.get('/api/notes');
const allNotes = await notesRes.json();
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
// And the owner pins the note (owner's pin state)
await page.request.patch(`/api/notes/${sharedNote.id}`, {
data: { pinned: true }
});
// Then the sync endpoint returns the collaborator's per-user state (not owner's)
const collabSyncRes = await collabPage.request.get(`/api/sync?since=0`);
const collabSyncedNotes = await collabSyncRes.json();
const collabSyncedNote = collabSyncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
// Collaborator should see their own pinned state (from noteUserState), not the owner's
// The collaborator pinned this note in a previous test, so it should be true for them
// but via their own per-user state, not leaked from the owner
expect(collabSyncedNote).toBeDefined();
});
test('Scenario: Collaborator sees leave action instead of trash', async () => {
// Then the collaborator's card shows a leave button, not a trash button
const card = noteCard(collabPage, 'Shared Note');
await card.hover();
await expect(card.getByTestId('leave-btn')).toBeVisible();
await expect(card.getByTestId('trash-btn')).not.toBeVisible();
});
test('Scenario: Collaborator leaves shared note and it disappears', async ({ authenticatedPage: page }) => {
// When the collaborator leaves the shared note
const card = noteCard(collabPage, 'Shared Note');
await card.hover();
await card.getByTestId('leave-btn').click({ force: true });
// Then the note disappears from the collaborator's list
await expect(noteCard(collabPage, 'Shared Note')).not.toBeVisible({ timeout: 5000 });
// But the owner still sees it
await page.reload();
await page.waitForLoadState('networkidle');
await expect(noteCard(page, 'Shared Note')).toBeVisible();
});
test.afterAll(async () => {
if (collabContext) await collabContext.close();
});
});
+6
View File
@@ -1,6 +1,12 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
$lib: path.resolve(__dirname, 'src/lib')
}
},
test: {
include: ['src/**/*.test.ts'],
environment: 'node'
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+10 -6
View File
@@ -4,15 +4,19 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crumbs - Your notes. Your server. Your rules.</title>
<meta name="description" content="Crumbs is a self-hostable, offline-first note-taking app. A privacy-respecting Google Keep alternative you can run on your own server with a single Docker command.">
<meta name="description" content="Crumbs is a self-hostable, offline-first note-taking app that respects your privacy. Deploy with a single Docker command.">
<meta property="og:title" content="Crumbs by Bretzel">
<meta property="og:description" content="Self-hostable, offline-first note-taking. A Google Keep alternative that respects your privacy.">
<meta property="og:description" content="A self-hostable, offline-first note-taking app that respects your privacy.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://crumbs.bretzel.app">
<meta property="og:image" content="https://crumbs.bretzel.app/assets/og-image.png">
<meta name="twitter:card" content="summary_large_image">
<meta property="og:image" content="https://crumbs.bretzel.app/assets/apple-touch-icon.png">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Crumbs by Bretzel">
<meta name="twitter:description" content="A self-hostable, offline-first note-taking app that respects your privacy.">
<meta name="twitter:image" content="https://crumbs.bretzel.app/assets/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="96x96" href="assets/favicon-96x96.png">
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
<script defer src="https://umami.bretzel.app/script.js" data-website-id="5969ad34-0b49-4af4-bddf-b026358a97af"></script>
<link rel="stylesheet" href="styles.css">
</head>
@@ -58,7 +62,7 @@
Your notes. Your server.<br><span>Your rules.</span>
</h1>
<p class="hero-subtitle animate-in delay-1">
A self-hostable, offline-first note-taking app. The Google Keep alternative that respects your privacy.
A self-hostable, offline-first note-taking app that respects your privacy.
</p>
<div class="hero-buttons animate-in delay-2">
<a href="#deploy" class="btn btn-primary">
@@ -249,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.10.0</span>
<span class="footer-version">v0.11.1</span>
</div>
</footer>