@knotpad/app 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/(app)/calendar/page.tsx +57 -0
- package/app/(app)/error.tsx +35 -0
- package/app/(app)/graph/page.tsx +32 -0
- package/app/(app)/guide/page.tsx +21 -0
- package/app/(app)/kanban/loading.tsx +24 -0
- package/app/(app)/kanban/page.tsx +59 -0
- package/app/(app)/layout.tsx +122 -0
- package/app/(app)/list/loading.tsx +21 -0
- package/app/(app)/list/page.tsx +137 -0
- package/app/(app)/loading.tsx +18 -0
- package/app/(app)/notes/[noteId]/page.tsx +84 -0
- package/app/(app)/notes/layout.tsx +30 -0
- package/app/(app)/notes/page.tsx +39 -0
- package/app/(app)/page.tsx +5 -0
- package/app/(app)/settings/agent-token/page.tsx +59 -0
- package/app/(app)/settings/backup/page.tsx +49 -0
- package/app/(app)/settings/billing/page.tsx +53 -0
- package/app/(app)/settings/calendar/page.tsx +41 -0
- package/app/(app)/settings/layout.test.tsx +39 -0
- package/app/(app)/settings/layout.tsx +71 -0
- package/app/(app)/settings/page.tsx +4 -0
- package/app/(app)/settings/security/page.tsx +43 -0
- package/app/(app)/settings/team/page.tsx +74 -0
- package/app/(app)/settings/workspace/page.tsx +27 -0
- package/app/(app)/tasks/[taskId]/page.tsx +79 -0
- package/app/(auth)/forgot-password/page.tsx +106 -0
- package/app/(auth)/guest/page.tsx +56 -0
- package/app/(auth)/layout.tsx +13 -0
- package/app/(auth)/login/page.tsx +14 -0
- package/app/(auth)/register/page.tsx +193 -0
- package/app/(auth)/reset-password/page.tsx +138 -0
- package/app/api/account/claim/route.tsx +135 -0
- package/app/api/admin/backfill-encryption/route.tsx +43 -0
- package/app/api/admin/license/route.tsx +42 -0
- package/app/api/auth/2fa/route.tsx +148 -0
- package/app/api/auth/[...nextauth]/route.tsx +3 -0
- package/app/api/auth/change-password/route.tsx +61 -0
- package/app/api/auth/check-2fa/route.tsx +19 -0
- package/app/api/auth/forgot-password/route.tsx +65 -0
- package/app/api/auth/reset-password/route.tsx +52 -0
- package/app/api/auth/verify-2fa/route.tsx +88 -0
- package/app/api/backup/download/db/route.ts +29 -0
- package/app/api/backup/download/notes/route.ts +25 -0
- package/app/api/backup/settings/route.ts +92 -0
- package/app/api/billing/checkout/route.tsx +81 -0
- package/app/api/billing/migrate/route.tsx +163 -0
- package/app/api/billing/portal/route.tsx +24 -0
- package/app/api/billing/setup-intent/route.tsx +55 -0
- package/app/api/billing/status/route.tsx +36 -0
- package/app/api/billing/subscribe/route.tsx +85 -0
- package/app/api/billing/webhook/route.tsx +199 -0
- package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
- package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
- package/app/api/calendar-feeds/events/route.tsx +82 -0
- package/app/api/calendar-feeds/route.tsx +52 -0
- package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
- package/app/api/cron/calendar-feeds/route.tsx +31 -0
- package/app/api/cron/stale-tasks/route.tsx +51 -0
- package/app/api/cron/sync/route.tsx +34 -0
- package/app/api/devices/[deviceId]/route.tsx +25 -0
- package/app/api/devices/route.tsx +41 -0
- package/app/api/export/route.tsx +40 -0
- package/app/api/feedback/route.tsx +54 -0
- package/app/api/folders/[folderId]/route.tsx +51 -0
- package/app/api/folders/route.tsx +37 -0
- package/app/api/graph/route.tsx +242 -0
- package/app/api/guest/route.tsx +58 -0
- package/app/api/health/route.tsx +10 -0
- package/app/api/holidays/countries/route.tsx +14 -0
- package/app/api/holidays/route.tsx +49 -0
- package/app/api/holidays/states/route.tsx +21 -0
- package/app/api/invites/[token]/route.tsx +131 -0
- package/app/api/invites/route.tsx +74 -0
- package/app/api/mcp/generate-token/route.tsx +55 -0
- package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
- package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
- package/app/api/notes/[noteId]/export/route.tsx +45 -0
- package/app/api/notes/[noteId]/route.tsx +360 -0
- package/app/api/notes/route.tsx +112 -0
- package/app/api/notifications/route.tsx +44 -0
- package/app/api/register/route.tsx +67 -0
- package/app/api/restore/route.tsx +148 -0
- package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
- package/app/api/sync/conflicts/route.tsx +48 -0
- package/app/api/sync/status/route.tsx +49 -0
- package/app/api/sync/trigger/route.tsx +15 -0
- package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
- package/app/api/tasks/[taskId]/route.tsx +259 -0
- package/app/api/tasks/bulk/route.tsx +133 -0
- package/app/api/tasks/route.tsx +36 -0
- package/app/api/workspace/active/route.tsx +39 -0
- package/app/api/workspace/create-team/route.tsx +42 -0
- package/app/api/workspace/kanban-statuses/route.tsx +71 -0
- package/app/api/workspace/members/[memberId]/route.tsx +69 -0
- package/app/api/workspace/route.tsx +24 -0
- package/app/download/page.tsx +170 -0
- package/app/favicon.ico +0 -0
- package/app/generated/prisma/client.d.ts +1 -0
- package/app/generated/prisma/client.js +5 -0
- package/app/generated/prisma/default.d.ts +1 -0
- package/app/generated/prisma/default.js +5 -0
- package/app/generated/prisma/edge.d.ts +1 -0
- package/app/generated/prisma/edge.js +497 -0
- package/app/generated/prisma/index-browser.js +523 -0
- package/app/generated/prisma/index.d.ts +46376 -0
- package/app/generated/prisma/index.js +497 -0
- package/app/generated/prisma/package.json +144 -0
- package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
- package/app/generated/prisma/runtime/client.d.ts +3386 -0
- package/app/generated/prisma/runtime/client.js +86 -0
- package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
- package/app/generated/prisma/runtime/index-browser.js +6 -0
- package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
- package/app/generated/prisma/schema.prisma +456 -0
- package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
- package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
- package/app/globals.css +54 -0
- package/app/invite/[token]/page.tsx +52 -0
- package/app/layout.tsx +90 -0
- package/app/mcp/route.tsx +430 -0
- package/app/opengraph-image.tsx +120 -0
- package/app/page.tsx +398 -0
- package/app/privacy/page.tsx +69 -0
- package/app/robots.tsx +25 -0
- package/app/sitemap.tsx +36 -0
- package/app/terms/page.tsx +69 -0
- package/app/upgrade/page.tsx +75 -0
- package/auth.config.ts +33 -0
- package/auth.ts +79 -0
- package/bin/brief.js +224 -0
- package/components/auth/login-form.tsx +302 -0
- package/components/auth/password-checklist.tsx +31 -0
- package/components/auth/password-input.tsx +36 -0
- package/components/auth/switch-account-button.test.tsx +22 -0
- package/components/auth/switch-account-button.tsx +19 -0
- package/components/auth/two-factor-input.tsx +116 -0
- package/components/billing/billing-dashboard.tsx +265 -0
- package/components/billing/card-form.tsx +210 -0
- package/components/billing/claim-account-form.tsx +99 -0
- package/components/branding/app-logo.test.tsx +20 -0
- package/components/branding/app-logo.tsx +25 -0
- package/components/calendar/calendar-agenda.tsx +150 -0
- package/components/calendar/calendar-drag.test.tsx +177 -0
- package/components/calendar/calendar-grid.tsx +357 -0
- package/components/calendar/calendar-hooks.test.tsx +27 -0
- package/components/calendar/calendar-hooks.ts +351 -0
- package/components/calendar/calendar-toolbar.test.tsx +68 -0
- package/components/calendar/calendar-toolbar.tsx +291 -0
- package/components/calendar/calendar-types.ts +148 -0
- package/components/calendar/calendar-view.test.tsx +295 -0
- package/components/calendar/calendar-view.tsx +307 -0
- package/components/calendar/day-detail-popover.tsx +174 -0
- package/components/calendar/task-chip.tsx +86 -0
- package/components/command/command-palette.test.tsx +33 -0
- package/components/command/command-palette.tsx +310 -0
- package/components/download-cta.tsx +87 -0
- package/components/feedback/feedback-popup.tsx +207 -0
- package/components/graph/graph-draw.ts +337 -0
- package/components/graph/graph-overlays.tsx +160 -0
- package/components/graph/graph-page.test.tsx +131 -0
- package/components/graph/graph-page.tsx +263 -0
- package/components/graph/graph-types.ts +47 -0
- package/components/graph/graph-view.tsx +322 -0
- package/components/guide/guide-view.tsx +522 -0
- package/components/kanban/kanban-board.test.tsx +128 -0
- package/components/kanban/kanban-board.tsx +361 -0
- package/components/kanban/kanban-card-menu.tsx +102 -0
- package/components/kanban/kanban-card.tsx +227 -0
- package/components/kanban/kanban-column.tsx +49 -0
- package/components/kanban/kanban-status-context.tsx +28 -0
- package/components/landing/calendar-sandbox.test.tsx +15 -0
- package/components/landing/calendar-sandbox.tsx +107 -0
- package/components/landing/graph-sandbox.test.tsx +27 -0
- package/components/landing/graph-sandbox.tsx +80 -0
- package/components/landing/kanban-sandbox.test.tsx +24 -0
- package/components/landing/kanban-sandbox.tsx +101 -0
- package/components/landing/landing-showcase.test.tsx +21 -0
- package/components/landing/landing-showcase.tsx +54 -0
- package/components/landing/list-sandbox.tsx +86 -0
- package/components/landing/mock-workspace.ts +168 -0
- package/components/landing/notes-sandbox.test.tsx +14 -0
- package/components/landing/notes-sandbox.tsx +88 -0
- package/components/layout/app-shell.tsx +83 -0
- package/components/layout/backup-scheduler.tsx +122 -0
- package/components/layout/bottom-nav.tsx +43 -0
- package/components/layout/icon-bar.test.tsx +29 -0
- package/components/layout/icon-bar.tsx +118 -0
- package/components/layout/mobile-top-bar.tsx +68 -0
- package/components/layout/notes-panel-folder.tsx +127 -0
- package/components/layout/notes-panel-note-item.tsx +140 -0
- package/components/layout/notes-panel-task-tab.tsx +63 -0
- package/components/layout/notes-panel-types.ts +44 -0
- package/components/layout/notes-panel.tsx +476 -0
- package/components/layout/notification-bell.tsx +251 -0
- package/components/layout/paywall-screen.tsx +41 -0
- package/components/layout/pro-banner.tsx +76 -0
- package/components/layout/sw-register.tsx +27 -0
- package/components/layout/workspace-switcher.tsx +90 -0
- package/components/notes/mobile-bottom-sheet.tsx +99 -0
- package/components/notes/note-editor-context-menu.tsx +47 -0
- package/components/notes/note-editor-dom.ts +33 -0
- package/components/notes/note-editor-dropdowns.tsx +484 -0
- package/components/notes/note-editor-hooks.ts +692 -0
- package/components/notes/note-editor-keyboard.ts +305 -0
- package/components/notes/note-editor-overlay.tsx +90 -0
- package/components/notes/note-editor.test.tsx +372 -0
- package/components/notes/note-editor.tsx +662 -0
- package/components/notes/note-preview-pane.tsx +156 -0
- package/components/notes/note-tabs.tsx +120 -0
- package/components/notes/note-types.tsx +157 -0
- package/components/settings/accept-invite.tsx +108 -0
- package/components/settings/agent-token-settings.tsx +369 -0
- package/components/settings/backup-restore-settings.test.tsx +25 -0
- package/components/settings/backup-restore-settings.tsx +327 -0
- package/components/settings/calendar-feeds-settings.tsx +489 -0
- package/components/settings/calendar-general-settings.tsx +174 -0
- package/components/settings/confirm-danger-action.test.tsx +215 -0
- package/components/settings/confirm-danger-action.tsx +65 -0
- package/components/settings/security-settings.tsx +252 -0
- package/components/settings/settings-guidance.test.tsx +98 -0
- package/components/settings/team-settings.tsx +319 -0
- package/components/settings/two-factor-auth.tsx +296 -0
- package/components/settings/workspace-settings-client.tsx +363 -0
- package/components/settings/workspace-settings-form.tsx +73 -0
- package/components/sync/conflict-viewer.tsx +247 -0
- package/components/sync/sync-indicator.tsx +171 -0
- package/components/tasks/snippet-thread.tsx +119 -0
- package/components/tasks/status-dot.tsx +47 -0
- package/components/tasks/task-badge.tsx +43 -0
- package/components/tasks/task-detail.test.tsx +187 -0
- package/components/tasks/task-detail.tsx +458 -0
- package/components/tasks/task-list-filters.test.tsx +75 -0
- package/components/tasks/task-list-filters.tsx +163 -0
- package/components/tasks/task-list-types.ts +20 -0
- package/components/tasks/task-list.test.tsx +175 -0
- package/components/tasks/task-list.tsx +481 -0
- package/components/tasks/task-row.tsx +85 -0
- package/components/tasks/task-table-row.tsx +259 -0
- package/components/ui/skeleton.tsx +3 -0
- package/components/ui/toast.test.tsx +42 -0
- package/components/ui/toast.tsx +70 -0
- package/instrumentation.tsx +23 -0
- package/lib/api-error.ts +50 -0
- package/lib/backup/backup-runner.test.ts +32 -0
- package/lib/backup/backup-runner.ts +19 -0
- package/lib/backup/backup-schedule.test.ts +23 -0
- package/lib/backup/backup-schedule.ts +55 -0
- package/lib/backup/backup-settings.test.ts +30 -0
- package/lib/backup/backup-settings.ts +27 -0
- package/lib/backup/export-notes-zip.test.ts +26 -0
- package/lib/backup/export-notes-zip.ts +82 -0
- package/lib/backup/export-workspace-backup.test.ts +17 -0
- package/lib/backup/export-workspace-backup.ts +77 -0
- package/lib/backup/restore-workspace-from-export.test.ts +18 -0
- package/lib/backup/restore-workspace-from-export.ts +183 -0
- package/lib/backup/types.ts +14 -0
- package/lib/brand-icons.ts +1 -0
- package/lib/calendar-feed-crypto.ts +38 -0
- package/lib/calendar-feed.ts +239 -0
- package/lib/client/online-status.ts +47 -0
- package/lib/conflict-resolver.test.ts +57 -0
- package/lib/conflict-resolver.ts +240 -0
- package/lib/db-init.ts +79 -0
- package/lib/email.ts +159 -0
- package/lib/encryption.test.ts +41 -0
- package/lib/encryption.ts +98 -0
- package/lib/extract-snippet.test.ts +123 -0
- package/lib/extract-snippet.ts +69 -0
- package/lib/kanban-status.ts +55 -0
- package/lib/license.ts +21 -0
- package/lib/limits.ts +31 -0
- package/lib/mcp-auth.test.ts +58 -0
- package/lib/mcp-auth.ts +65 -0
- package/lib/mcp-contract.test.ts +25 -0
- package/lib/mcp-contract.ts +210 -0
- package/lib/mcp-handler.ts +31 -0
- package/lib/mcp-url.test.ts +12 -0
- package/lib/mcp-url.ts +7 -0
- package/lib/mentions.test.ts +45 -0
- package/lib/mentions.ts +73 -0
- package/lib/note-crypto.ts +108 -0
- package/lib/note-sync.ts +201 -0
- package/lib/note-title.ts +93 -0
- package/lib/prisma.ts +193 -0
- package/lib/pro-flush.ts +292 -0
- package/lib/rate-limit.ts +57 -0
- package/lib/stripe.ts +38 -0
- package/lib/sync-worker.ts +388 -0
- package/lib/task-parser.test.ts +91 -0
- package/lib/task-parser.ts +81 -0
- package/lib/task-utils.ts +52 -0
- package/lib/use-is-electron.ts +19 -0
- package/lib/use-is-mobile.ts +22 -0
- package/lib/validation/calendar-feed.ts +31 -0
- package/lib/validation/note.ts +27 -0
- package/lib/validation/task.ts +26 -0
- package/lib/view-preferences.test.ts +54 -0
- package/lib/view-preferences.ts +28 -0
- package/lib/workspace.ts +66 -0
- package/next.config.ts +21 -0
- package/package.json +49 -3
- package/postcss.config.mjs +7 -0
- package/prisma/migrations/20260519021916_init/migration.sql +388 -0
- package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
- package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
- package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
- package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
- package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
- package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
- package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
- package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
- package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
- package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
- package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
- package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
- package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
- package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
- package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
- package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
- package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +457 -0
- package/public/Logo_icon.svg +1 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +4 -0
- package/public/icon_dark.svg +1 -0
- package/public/knotpad_icon.svg +1 -0
- package/public/knotpad_logo_full.svg +1 -0
- package/public/manifest.json +14 -0
- package/public/next.svg +1 -0
- package/public/sw.js +137 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +35 -0
- package/brief.js +0 -311
package/lib/pro-flush.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud-first sync worker for Pro workspaces.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the bidirectional runSync for Pro users. Direction is strictly
|
|
5
|
+
* local → cloud: flush records written offline (pendingSync=true) to Neon,
|
|
6
|
+
* auto-merge when possible, log conflicts when not.
|
|
7
|
+
*
|
|
8
|
+
* The bidirectional runSync in sync-worker.ts is kept for reference but no
|
|
9
|
+
* longer called by the cron or trigger routes once a workspace is cloud-first.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { prisma, getCloudPrisma } from "@/lib/prisma";
|
|
13
|
+
import {
|
|
14
|
+
lastWriteWins,
|
|
15
|
+
threeWayMerge,
|
|
16
|
+
isTombstoned,
|
|
17
|
+
logConflict,
|
|
18
|
+
} from "@/lib/conflict-resolver";
|
|
19
|
+
import {
|
|
20
|
+
getOrCreateSyncState,
|
|
21
|
+
getSnapshots,
|
|
22
|
+
saveSnapshots,
|
|
23
|
+
} from "@/lib/note-sync";
|
|
24
|
+
import { encryptForSync, decryptFromSync } from "@/lib/note-crypto";
|
|
25
|
+
import { isWorkspacePro, isWorkspaceCloud } from "@/lib/license";
|
|
26
|
+
import type { SyncResult, SyncStatus } from "@/lib/sync-worker";
|
|
27
|
+
|
|
28
|
+
export type { SyncResult, SyncStatus };
|
|
29
|
+
|
|
30
|
+
// Per-process concurrency guard (fast path — avoids DB round-trip when a flush
|
|
31
|
+
// is already in-flight on this instance).
|
|
32
|
+
const inFlight = new Set<string>();
|
|
33
|
+
|
|
34
|
+
// Lock TTL: if a lock is older than this, it's considered stale (process crashed
|
|
35
|
+
// mid-flush) and a new flush is allowed to proceed.
|
|
36
|
+
const LOCK_TTL_MS = 120_000; // 2 minutes
|
|
37
|
+
|
|
38
|
+
export async function runProFlush(workspaceId: string): Promise<SyncResult> {
|
|
39
|
+
const workspace = await prisma.workspace.findUnique({
|
|
40
|
+
where: { id: workspaceId },
|
|
41
|
+
select: { planType: true, licenseType: true },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!workspace || !isWorkspacePro(workspace) || !isWorkspaceCloud(workspace)) {
|
|
45
|
+
return { status: "synced", notesProcessed: 0, tasksProcessed: 0, conflictsFound: 0 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const cloudPrisma = getCloudPrisma();
|
|
49
|
+
if (!cloudPrisma) {
|
|
50
|
+
return {
|
|
51
|
+
status: "error",
|
|
52
|
+
notesProcessed: 0,
|
|
53
|
+
tasksProcessed: 0,
|
|
54
|
+
conflictsFound: 0,
|
|
55
|
+
error: "CLOUD_DATABASE_URL is not configured",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fast path: per-process guard
|
|
60
|
+
if (inFlight.has(workspaceId)) {
|
|
61
|
+
return { status: "syncing", notesProcessed: 0, tasksProcessed: 0, conflictsFound: 0 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Distributed lock: atomically claim the lock only if there is none (or it's stale).
|
|
65
|
+
// This prevents two Node processes / Dokploy replicas from racing on the same workspace.
|
|
66
|
+
// We upsert first to ensure the row exists (first-ever sync), then attempt the update.
|
|
67
|
+
const lockCutoff = new Date(Date.now() - LOCK_TTL_MS);
|
|
68
|
+
const now = new Date();
|
|
69
|
+
|
|
70
|
+
// Ensure the SyncState row exists before we try to lock it.
|
|
71
|
+
await prisma.syncState.upsert({
|
|
72
|
+
where: { workspaceId },
|
|
73
|
+
create: { workspaceId },
|
|
74
|
+
update: {}, // no-op if it already exists; lock attempt is below
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const locked = await prisma.syncState.updateMany({
|
|
78
|
+
where: {
|
|
79
|
+
workspaceId,
|
|
80
|
+
OR: [
|
|
81
|
+
{ syncLockedAt: null },
|
|
82
|
+
{ syncLockedAt: { lt: lockCutoff } },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
data: { syncLockedAt: now },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (locked.count === 0) {
|
|
89
|
+
// Another instance holds a fresh lock
|
|
90
|
+
return { status: "syncing", notesProcessed: 0, tasksProcessed: 0, conflictsFound: 0 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
inFlight.add(workspaceId);
|
|
94
|
+
const syncStart = new Date();
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await getOrCreateSyncState(workspaceId, cloudPrisma);
|
|
98
|
+
const { snapshots } = await getSnapshots(workspaceId, cloudPrisma);
|
|
99
|
+
|
|
100
|
+
let notesProcessed = 0;
|
|
101
|
+
let tasksProcessed = 0;
|
|
102
|
+
let conflictsFound = 0;
|
|
103
|
+
|
|
104
|
+
// ── Flush pending notes ────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
const pendingNotes = await prisma.note.findMany({
|
|
107
|
+
where: { workspaceId, pendingSync: true },
|
|
108
|
+
orderBy: { updatedAt: "asc" },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
for (const localNote of pendingNotes) {
|
|
112
|
+
if (await isTombstoned(workspaceId, "note", localNote.id, cloudPrisma)) {
|
|
113
|
+
await prisma.note.update({ where: { id: localNote.id }, data: { pendingSync: false } });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const cloudNote = await cloudPrisma.note.findUnique({ where: { id: localNote.id } });
|
|
118
|
+
|
|
119
|
+
if (!cloudNote) {
|
|
120
|
+
// New note written offline — push to cloud
|
|
121
|
+
await cloudPrisma.note.upsert({
|
|
122
|
+
where: { id: localNote.id },
|
|
123
|
+
create: {
|
|
124
|
+
id: localNote.id,
|
|
125
|
+
workspaceId: localNote.workspaceId,
|
|
126
|
+
title: localNote.title,
|
|
127
|
+
content: localNote.content,
|
|
128
|
+
folderId: localNote.folderId,
|
|
129
|
+
isLocked: localNote.isLocked,
|
|
130
|
+
version: localNote.version,
|
|
131
|
+
deviceId: localNote.deviceId,
|
|
132
|
+
createdAt: localNote.createdAt,
|
|
133
|
+
updatedAt: localNote.updatedAt,
|
|
134
|
+
},
|
|
135
|
+
update: {
|
|
136
|
+
title: localNote.title,
|
|
137
|
+
content: localNote.content,
|
|
138
|
+
version: localNote.version,
|
|
139
|
+
updatedAt: localNote.updatedAt,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
snapshots[localNote.id] = await decryptFromSync(localNote.content, workspaceId);
|
|
143
|
+
await prisma.note.update({ where: { id: localNote.id }, data: { pendingSync: false } });
|
|
144
|
+
notesProcessed++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Cloud version exists — check for conflict (dual-offline scenario)
|
|
149
|
+
if (localNote.version <= cloudNote.version && localNote.updatedAt <= cloudNote.updatedAt) {
|
|
150
|
+
// Cloud is newer; discard local buffer and pull cloud down
|
|
151
|
+
await prisma.note.update({
|
|
152
|
+
where: { id: localNote.id },
|
|
153
|
+
data: {
|
|
154
|
+
content: cloudNote.content,
|
|
155
|
+
title: cloudNote.title,
|
|
156
|
+
version: cloudNote.version,
|
|
157
|
+
updatedAt: cloudNote.updatedAt,
|
|
158
|
+
pendingSync: false,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
snapshots[localNote.id] = await decryptFromSync(cloudNote.content, workspaceId);
|
|
162
|
+
notesProcessed++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const base = snapshots[localNote.id] ?? "";
|
|
167
|
+
const localPlain = await decryptFromSync(localNote.content, workspaceId);
|
|
168
|
+
const cloudPlain = await decryptFromSync(cloudNote.content, workspaceId);
|
|
169
|
+
const result = threeWayMerge(base, localPlain, cloudPlain);
|
|
170
|
+
|
|
171
|
+
if (!result.hasConflicts) {
|
|
172
|
+
const nextVersion = Math.max(localNote.version, cloudNote.version) + 1;
|
|
173
|
+
const mergedCipher = await encryptForSync(result.merged, workspaceId);
|
|
174
|
+
await Promise.all([
|
|
175
|
+
cloudPrisma.note.update({
|
|
176
|
+
where: { id: localNote.id },
|
|
177
|
+
data: { content: mergedCipher, version: nextVersion, updatedAt: new Date() },
|
|
178
|
+
}),
|
|
179
|
+
prisma.note.update({
|
|
180
|
+
where: { id: localNote.id },
|
|
181
|
+
data: { content: mergedCipher, version: nextVersion, pendingSync: false },
|
|
182
|
+
}),
|
|
183
|
+
]);
|
|
184
|
+
snapshots[localNote.id] = result.merged;
|
|
185
|
+
} else {
|
|
186
|
+
// Genuine dual-offline conflict — log it in Neon (visible from any device)
|
|
187
|
+
await logConflict(workspaceId, "note", localNote.id, localNote.content, cloudNote.content, cloudPrisma);
|
|
188
|
+
conflictsFound++;
|
|
189
|
+
// Leave pendingSync=true so the record is retried after the conflict is resolved
|
|
190
|
+
}
|
|
191
|
+
notesProcessed++;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Flush pending tasks ────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
const pendingTasks = await prisma.task.findMany({
|
|
197
|
+
where: { workspaceId, pendingSync: true },
|
|
198
|
+
orderBy: { updatedAt: "asc" },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
for (const localTask of pendingTasks) {
|
|
202
|
+
if (await isTombstoned(workspaceId, "task", localTask.id, cloudPrisma)) {
|
|
203
|
+
await prisma.task.update({ where: { id: localTask.id }, data: { pendingSync: false } });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const cloudTask = await cloudPrisma.task.findUnique({ where: { id: localTask.id } });
|
|
208
|
+
|
|
209
|
+
if (!cloudTask) {
|
|
210
|
+
await cloudPrisma.task.upsert({
|
|
211
|
+
where: { id: localTask.id },
|
|
212
|
+
create: {
|
|
213
|
+
id: localTask.id,
|
|
214
|
+
workspaceId: localTask.workspaceId,
|
|
215
|
+
noteId: localTask.noteId,
|
|
216
|
+
title: localTask.title,
|
|
217
|
+
status: localTask.status,
|
|
218
|
+
assigneeType: localTask.assigneeType,
|
|
219
|
+
assigneeId: localTask.assigneeId,
|
|
220
|
+
fileRefs: localTask.fileRefs,
|
|
221
|
+
startDate: localTask.startDate,
|
|
222
|
+
dueDate: localTask.dueDate,
|
|
223
|
+
version: localTask.version,
|
|
224
|
+
createdAt: localTask.createdAt,
|
|
225
|
+
updatedAt: localTask.updatedAt,
|
|
226
|
+
},
|
|
227
|
+
update: {
|
|
228
|
+
status: localTask.status,
|
|
229
|
+
version: localTask.version,
|
|
230
|
+
updatedAt: localTask.updatedAt,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
await prisma.task.update({ where: { id: localTask.id }, data: { pendingSync: false } });
|
|
234
|
+
tasksProcessed++;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const winner = lastWriteWins(
|
|
239
|
+
{ id: localTask.id, updatedAt: localTask.updatedAt, version: localTask.version },
|
|
240
|
+
{ id: cloudTask.id, updatedAt: cloudTask.updatedAt, version: cloudTask.version }
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
if (winner === "local") {
|
|
244
|
+
await cloudPrisma.task.update({
|
|
245
|
+
where: { id: localTask.id },
|
|
246
|
+
data: { status: localTask.status, version: localTask.version, updatedAt: localTask.updatedAt },
|
|
247
|
+
});
|
|
248
|
+
} else {
|
|
249
|
+
await prisma.task.update({
|
|
250
|
+
where: { id: localTask.id },
|
|
251
|
+
data: { status: cloudTask.status, version: cloudTask.version },
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
await prisma.task.update({ where: { id: localTask.id }, data: { pendingSync: false } });
|
|
255
|
+
tasksProcessed++;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Finalize ──────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
await saveSnapshots(workspaceId, snapshots, undefined, cloudPrisma);
|
|
261
|
+
// Mirror snapshots to local PGlite so three-way merge bases are available
|
|
262
|
+
// locally (e.g. for offline resilience or future single-note merge paths).
|
|
263
|
+
await saveSnapshots(workspaceId, snapshots, undefined, prisma);
|
|
264
|
+
|
|
265
|
+
const cloudSyncState = await cloudPrisma.syncState.upsert({
|
|
266
|
+
where: { workspaceId },
|
|
267
|
+
update: { lastSyncedAt: syncStart, cloudVersion: { increment: 1 } },
|
|
268
|
+
create: { workspaceId, lastSyncedAt: syncStart },
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Mirror sync timestamp to local PGlite so the status API (which reads
|
|
272
|
+
// from local prisma) returns a fresh lastSyncedAt without requiring the
|
|
273
|
+
// user to press the sync button.
|
|
274
|
+
await prisma.syncState.updateMany({
|
|
275
|
+
where: { workspaceId },
|
|
276
|
+
data: {
|
|
277
|
+
lastSyncedAt: syncStart,
|
|
278
|
+
cloudVersion: cloudSyncState.cloudVersion,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const status: SyncStatus = conflictsFound > 0 ? "conflict" : "synced";
|
|
283
|
+
return { status, notesProcessed, tasksProcessed, conflictsFound };
|
|
284
|
+
} finally {
|
|
285
|
+
// Release both the in-process guard and the distributed DB lock.
|
|
286
|
+
inFlight.delete(workspaceId);
|
|
287
|
+
await prisma.syncState.updateMany({
|
|
288
|
+
where: { workspaceId },
|
|
289
|
+
data: { syncLockedAt: null },
|
|
290
|
+
}).catch((err) => console.error("[brief/pro-flush] failed to release sync lock:", err));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process sliding-window rate limiter.
|
|
3
|
+
* Works for single-process local-first deployment.
|
|
4
|
+
* For cloud multi-process, swap the store for a Redis-backed implementation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type Window = { count: number; resetAt: number };
|
|
8
|
+
const store = new Map<string, Window>();
|
|
9
|
+
|
|
10
|
+
// Purge expired entries every 5 minutes to prevent memory leaks
|
|
11
|
+
setInterval(() => {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
for (const [key, w] of store) {
|
|
14
|
+
if (w.resetAt < now) store.delete(key);
|
|
15
|
+
}
|
|
16
|
+
}, 5 * 60_000);
|
|
17
|
+
|
|
18
|
+
export type RateLimitResult =
|
|
19
|
+
| { limited: false }
|
|
20
|
+
| { limited: true; retryAfter: number };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check and record a hit for the given key.
|
|
24
|
+
* @param key Unique identifier (e.g. IP + route prefix)
|
|
25
|
+
* @param max Max requests allowed in windowMs
|
|
26
|
+
* @param windowMs Window duration in milliseconds
|
|
27
|
+
*/
|
|
28
|
+
export function rateLimit(
|
|
29
|
+
key: string,
|
|
30
|
+
max: number,
|
|
31
|
+
windowMs: number
|
|
32
|
+
): RateLimitResult {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const existing = store.get(key);
|
|
35
|
+
|
|
36
|
+
if (!existing || existing.resetAt < now) {
|
|
37
|
+
store.set(key, { count: 1, resetAt: now + windowMs });
|
|
38
|
+
return { limited: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
existing.count += 1;
|
|
42
|
+
if (existing.count > max) {
|
|
43
|
+
const retryAfter = Math.ceil((existing.resetAt - now) / 1000);
|
|
44
|
+
return { limited: true, retryAfter };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { limited: false };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Extract client IP from Next.js request headers. */
|
|
51
|
+
export function getClientIp(req: Request): string {
|
|
52
|
+
return (
|
|
53
|
+
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
54
|
+
req.headers.get("x-real-ip") ??
|
|
55
|
+
"unknown"
|
|
56
|
+
);
|
|
57
|
+
}
|
package/lib/stripe.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import Stripe from "stripe";
|
|
2
|
+
|
|
3
|
+
// Hard-fail at request time if the key is missing — never silently fall back to test mode.
|
|
4
|
+
// During `next build` the module is imported but stripe is never called, so this is safe.
|
|
5
|
+
const _secretKey = process.env.STRIPE_SECRET_KEY;
|
|
6
|
+
|
|
7
|
+
export function getStripe(): Stripe {
|
|
8
|
+
if (!_secretKey) throw new Error("STRIPE_SECRET_KEY env var is required");
|
|
9
|
+
return _stripe;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Module-level instance is fine for server-side use (Node.js singleton).
|
|
13
|
+
// We initialise with the real key when present, or a dummy string during build
|
|
14
|
+
// (Next.js evaluates module level code at build time with empty env).
|
|
15
|
+
const _stripe = new Stripe(_secretKey ?? "sk_build_placeholder_never_used", {
|
|
16
|
+
apiVersion: "2026-05-27.dahlia",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Use `stripe` for Stripe API calls. It throws at call time if STRIPE_SECRET_KEY
|
|
21
|
+
* is not set in the environment, keeping production safe.
|
|
22
|
+
*/
|
|
23
|
+
export const stripe: Stripe = new Proxy(_stripe, {
|
|
24
|
+
get(target, prop) {
|
|
25
|
+
if (!_secretKey) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`STRIPE_SECRET_KEY is not configured. Cannot call stripe.${String(prop)}()`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const val = (target as unknown as Record<string | symbol, unknown>)[prop];
|
|
31
|
+
return typeof val === "function" ? val.bind(target) : val;
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const PRICE_ID_PERSONAL_PRO = process.env.STRIPE_PRICE_ID_PERSONAL_PRO ?? "";
|
|
36
|
+
export const PRICE_ID_TEAM_PRO = process.env.STRIPE_PRICE_ID_TEAM_PRO ?? "";
|
|
37
|
+
export const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
|
38
|
+
export const STRIPE_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "";
|