From ab4c50310d9844b05d84dab87a5fb326e907600f Mon Sep 17 00:00:00 2001 From: Kamil Date: Sun, 13 Sep 2026 10:13:52 +0100 Subject: [PATCH 1/2] docs: rewrite documentation against the code, add diagrams and Cmd+B Content - Every page rewritten and verified against convex/ and src/: real function names (listApprovedJobs, postJob, getUserProfile, updateUserName, deleteAccount, updateBookmarkStatus, ...), all six tables and indexes, roles in profiles.role (not Clerk metadata), no /admin route or middleware protection, password + email code + OAuth (no magic link), exact submission limits and rate limits, 14/30-day job lifecycle with the batched cron, bookmark/tracking rules, stable unsubscribe tokens - New pages: api-reference/email-subscriptions, technical/project-structure (was a broken sidebar link) - Deployment and contributing pages reflect the single ci.yml, deploy.yml, the skipped Vercel job, env var names per deployment, `bunx tsc --noEmit` instead of the never-exiting `bun run type-check` - Removed duplicate H1s; frontmatter title/description render the heading Diagrams - Mermaid client component (theme-aware, lazy-loaded, strict security, accessible label, collapsible source) registered for MDX - 12 diagrams: system overview, request/subscription sequence, job lifecycle, approval fan-out, sign-in sequence, authorization map, application status machine (x2), schema ER, deploy pipeline, posting flow, notification flow Interactivity - Cmd+B / Ctrl+B toggles the sidebar (collapse on desktop, drawer on mobile), ignored while typing; kbd hint in the docs toolbar - Steps/Step and Tabs/Tab registered as MDX components Tests: 7 new unit tests for the shortcut and Mermaid components. --- bun.lock | 235 +++++++++++++++++- content/docs/api-reference/admin.mdx | 167 +++++++++---- content/docs/api-reference/bookmarks.mdx | 204 +++++++++++---- .../api-reference/email-subscriptions.mdx | 156 ++++++++++++ content/docs/api-reference/jobs.mdx | 185 ++++++++++---- content/docs/api-reference/meta.json | 2 +- content/docs/api-reference/profiles.mdx | 145 +++++++---- content/docs/architecture/authentication.mdx | 160 ++++++++---- content/docs/architecture/data-flow.mdx | 174 +++++++++---- content/docs/architecture/overview.mdx | 128 +++++++--- content/docs/architecture/tech-stack.mdx | 81 ++++-- content/docs/index.mdx | 102 ++++++-- content/docs/technical/contributing.mdx | 174 ++++++++++--- content/docs/technical/database-schema.mdx | 221 +++++++++++++--- content/docs/technical/deployment.mdx | 171 +++++++++---- content/docs/technical/project-structure.mdx | 175 +++++++++++++ content/docs/user-guide/browsing-jobs.mdx | 58 +++-- .../docs/user-guide/email-notifications.mdx | 97 ++++++-- content/docs/user-guide/getting-started.mdx | 98 ++++++-- content/docs/user-guide/meta.json | 8 +- content/docs/user-guide/posting-jobs.mdx | 117 ++++++--- .../docs/user-guide/tracking-applications.mdx | 115 +++++++-- convex/bookmarks.ts | 4 +- package.json | 1 + src/app/docs/keyboard-shortcuts.tsx | 44 ++++ src/app/docs/layout.tsx | 2 + src/app/docs/nav-toolbar.tsx | 14 ++ src/components/docs/Mermaid.tsx | 157 ++++++++++++ src/mdx-components.tsx | 8 + .../components/DocsKeyboardShortcuts.test.tsx | 76 ++++++ tests/components/Mermaid.test.tsx | 59 +++++ 31 files changed, 2737 insertions(+), 601 deletions(-) create mode 100644 content/docs/api-reference/email-subscriptions.mdx create mode 100644 content/docs/technical/project-structure.mdx create mode 100644 src/app/docs/keyboard-shortcuts.tsx create mode 100644 src/components/docs/Mermaid.tsx create mode 100644 tests/components/DocsKeyboardShortcuts.test.tsx create mode 100644 tests/components/Mermaid.test.tsx diff --git a/bun.lock b/bun.lock index 6af7904..530c7ec 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "fumadocs-mdx": "^14.2.6", "fumadocs-ui": "^16.5.0", "lucide-react": "^0.513.0", + "mermaid": "^12.0.0", "next": "16.3.5", "next-themes": "^0.4.6", "react": "^19.2.4", @@ -47,6 +48,8 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@antfu/install-pkg": ["@antfu/install-pkg@2.0.1", "", { "dependencies": { "package-manager-detector": "^1.7.0", "tinyexec": "^1.2.4" } }, "sha512-iCKVQcIC0e3oDxEfs3SHQGW+ovhBMZmS1TE+bTk50rVyMCBmCfClv7Qi3HQKlumYwvjb/iIMeWCW2i67q6kFfQ=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], @@ -121,6 +124,18 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="], + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.1.2", "", { "dependencies": { "@chevrotain/gast": "11.1.2", "@chevrotain/types": "11.1.2", "lodash-es": "4.17.23" } }, "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q=="], + + "@chevrotain/gast": ["@chevrotain/gast@11.1.2", "", { "dependencies": { "@chevrotain/types": "11.1.2", "lodash-es": "4.17.23" } }, "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g=="], + + "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.1.2", "", {}, "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw=="], + + "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], + + "@chevrotain/utils": ["@chevrotain/utils@11.1.2", "", {}, "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA=="], + "@clerk/backend": ["@clerk/backend@2.33.3", "", { "dependencies": { "@clerk/shared": "^3.47.5", "@clerk/types": "^4.101.23", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-cgkFVEYFG2nZn4QDuYBhiAwPtMdo8Yj7DAtq/SBQ5C/ainh3uxNRDgUj4bFn52qJkWLiCkraYJIw1b8dEUbUBg=="], "@clerk/clerk-react": ["@clerk/clerk-react@5.61.6", "", { "dependencies": { "@clerk/shared": "^3.47.5", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "sha512-OiyBlrnkRr9IhZtPd7EwlzhYScBpvNKJ8lgg7Uw6JElzJYz854IeQaez5mAfpiib3LcW/Dn53E2PQhagcuLJ3Q=="], @@ -249,6 +264,10 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@3.1.7", "", { "dependencies": { "@antfu/install-pkg": "^2.0.1", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], @@ -359,6 +378,8 @@ "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], + "@mermaid-js/parser": ["@mermaid-js/parser@2.0.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-K8BeapFUfrfxbRAUQAG5oBOCwo1+bNWzaVnPxTCutkfrTBn6T/j91FIhqxWJ32SUeQ9T3iy1zcmPZ5ROZEvDrg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@next/bundle-analyzer": ["@next/bundle-analyzer@15.5.11", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-G9JIdFJGvQuIXIkDImoTFQQjas0Hx2FKDHk/8lsSxPNhv/9aye5t1z0sA4U0VmyPKbIPRh+/yxhoDUiMsTreSw=="], @@ -551,12 +572,76 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.1", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.4", "", {}, "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-shape": ["@types/d3-shape@3.2.0", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], @@ -589,6 +674,8 @@ "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], @@ -655,6 +742,8 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -761,6 +850,8 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "chevrotain": ["chevrotain@11.1.2", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.1.2", "@chevrotain/gast": "11.1.2", "@chevrotain/regexp-to-ast": "11.1.2", "@chevrotain/types": "11.1.2", "@chevrotain/utils": "11.1.2", "lodash-es": "4.17.23" } }, "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], @@ -799,6 +890,8 @@ "convex-helpers": ["convex-helpers@0.1.112", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-zhcm14aaRRNQYwxsFngmfYsGB3V0HwJ/hHMfKI9eeTR1N0b9o5Rohqvz3NgzyVJcmGNvYlZpUPbOy2YL5sYQNw=="], + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], @@ -809,6 +902,78 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cytoscape": ["cytoscape@3.34.3", "", {}, "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], @@ -819,6 +984,8 @@ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="], + "debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -837,6 +1004,8 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -851,6 +1020,8 @@ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], @@ -859,6 +1030,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.427", "", {}, "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw=="], + "elkjs": ["elkjs@0.9.3", "", {}, "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ=="], + "emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -885,6 +1058,8 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="], + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], @@ -1037,6 +1212,8 @@ "gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="], + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], "harmony-reflect": ["harmony-reflect@1.6.2", "", {}, "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g=="], @@ -1089,6 +1266,8 @@ "import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], @@ -1101,6 +1280,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], @@ -1269,12 +1450,18 @@ "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -1307,6 +1494,8 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="], + "lodash.memoize": ["lodash.memoize@4.1.2", "", {}, "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -1333,6 +1522,8 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], @@ -1371,6 +1562,8 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "mermaid": ["mermaid@12.0.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^2.0.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "chevrotain": "~11.1.2", "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.21", "dompurify": "^3.4.12", "elkjs": "^0.9.3", "es-toolkit": "^1.45.1", "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-/wQXC9iBxoGV8p3erbvaXs9h77VyLDBH6GdayVjj3hEcSQhFU4N1WUhUppotCEqlIxI2pRMwjwBSwTB1MfZBgQ=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1521,6 +1714,8 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], @@ -1529,6 +1724,8 @@ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], @@ -1549,6 +1746,10 @@ "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postal-mime": ["postal-mime@2.7.3", "", {}, "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw=="], @@ -1639,10 +1840,16 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], @@ -1747,6 +1954,8 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1791,6 +2000,8 @@ "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], + "ts-jest": ["ts-jest@29.4.6", "", { "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", "semver": "^7.7.3", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "optionalPeers": ["@babel/core", "@jest/transform", "@jest/types", "babel-jest", "jest-util"], "bin": { "ts-jest": "cli.js" } }, "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA=="], "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], @@ -1849,7 +2060,7 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="], "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], @@ -1913,6 +2124,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@antfu/install-pkg/tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -2013,6 +2226,14 @@ "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + + "dagre-d3-es/lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -2049,6 +2270,8 @@ "jsdom/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -2073,6 +2296,8 @@ "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "svix/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], @@ -2129,6 +2354,12 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "fumadocs-mdx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "fumadocs-mdx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], @@ -2191,6 +2422,8 @@ "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "resend/svix/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "@babel/helper-module-transforms/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helpers/@babel/template/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], diff --git a/content/docs/api-reference/admin.mdx b/content/docs/api-reference/admin.mdx index c95a90c..c2427b6 100644 --- a/content/docs/api-reference/admin.mdx +++ b/content/docs/api-reference/admin.mdx @@ -1,90 +1,161 @@ --- title: Admin API -description: Convex functions for admin operations +description: Convex functions for moderating job submissions --- -# Admin API +`convex/admin.ts` is the moderation queue: read what is waiting, approve or reject it, close listings that have gone stale, and delete outright. Every function except `checkUserRole` is gated by the same helper, and approval is the one action in the whole backend that sends email to users. -Functions for moderating jobs and managing the platform. +## Authorization -## Queries +`requireModOrAdmin` runs first in every gated function. It resolves the Clerk identity, loads the caller's `profiles` row, and requires `role` to be `moderator` or `admin`. -### `getPendingJobs` +| Situation | Error thrown | +|---|---| +| No Clerk identity on the request | `Not authenticated` | +| No profile row, or `role` is `user` | `Unauthorized` | -Get all jobs pending review. +`admin` and `moderator` have identical backend permissions — nothing in `convex/admin.ts` distinguishes them. The split only exists in the UI. Roles are set by editing the `profiles` row in the Convex dashboard; no function promotes a user. -```typescript -const pending = useQuery(api.admin.getPendingJobs); -``` +## `checkUserRole` -**Authentication:** Admin or Moderator role required +| | | +|---|---| +| Kind | `query` | +| Auth | Anonymous allowed | +| Args | none | -**Returns:** Array of pending job submissions. +**Returns:** -### `getApprovedJobs` +| Caller | Shape | +|---|---| +| Signed out | `{ isAdmin: false, isModerator: false }` | +| Signed in, no profile row | `{ isAdmin: false, isModerator: false, userEmail }` | +| Signed in with a profile | `{ isAdmin, isModerator, userEmail, role }` | -Get all approved jobs. +`isModerator` is `true` for admins as well as moderators, so it is the flag to branch on for moderation UI. -```typescript -const approvedJobs = useQuery(api.admin.getApprovedJobs); -``` + + The app does not call this query. `dashboard.getDashboardData` returns the same object as its `userRole` field, alongside the user's bookmarks and the pending queue, in one round trip. + + +## `getPendingJobs` + +| | | +|---|---| +| Kind | `query` | +| Auth | Moderator or admin | +| Args | none | + +**Returns:** every job with `status: "pending"`, newest first, as `{ id, createdAt, title, company, location, url, status, tags }`. Note `id` and `createdAt`, not the raw `_id` and `_creationTime`. + +Superseded in the app by the `pendingJobs` field of `dashboard.getDashboardData`, which returns the identical shape and is empty for non-moderators. + +## `getPendingJobsCount` + +| | | +|---|---| +| Kind | `query` | +| Auth | Moderator or admin | +| Args | none | + +**Returns:** a `number` — the length of the pending queue. Currently unused by the app. + +## `getApprovedJobs` -**Authentication:** Admin or Moderator role required +| | | +|---|---| +| Kind | `query` | +| Auth | Moderator or admin | +| Args | none | -**Returns:** Array of approved jobs. +**Returns:** every approved job, newest first, in the same shape as `getPendingJobs`. This is the moderator's view of live listings in `AdminSection`, and unlike [`jobs.listApprovedJobs`](/docs/api-reference/jobs) it carries no bookmark state and applies no filters. -## Mutations +## `updateJobStatus` -### `updateJobStatus` +Approves or rejects a submission. -Approve or reject a pending job submission. +| | | +|---|---| +| Kind | `mutation` | +| Auth | Moderator or admin | + +| Arg | Type | Required | +|---|---|---| +| `jobId` | `Id<"jobs">` | Yes | +| `status` | `"approved" \| "rejected"` | Yes | + +**Returns:** `{ success: true }` + +**Throws:** `Not authenticated`, `Unauthorized`, `Job not found`. ```typescript const updateJobStatus = useMutation(api.admin.updateJobStatus); - await updateJobStatus({ jobId, status: "approved" }); ``` -**Authentication:** Admin or Moderator role required +Approving flips the job into `listApprovedJobs` immediately — Convex pushes the new result to every subscribed client without a refetch. -**Parameters:** -- `jobId` - ID of job to update -- `status` - `"approved"` or `"rejected"` +### Email triggered on approval -**Returns:** Confirmation object. +When the previous status was not already `approved` and the new status is `approved`, the mutation schedules `internal.notifications.sendJobApprovedUserEmails` for that job. That action: -### `markJobOutdated` +- re-reads the job and stops unless it is still `approved`; +- fetches active subscribers whose `roleFilters` include the job's `roleLevel` (an empty filter list means all role levels); +- skips any subscriber who already has a `jobNotificationDeliveries` row for this job, and records one after each successful send. -Mark an approved job as outdated. +Rejecting sends nothing. Re-approving an already-approved job sends nothing, because the guard compares against the previous status. Details in [Email Subscriptions API](/docs/api-reference/email-subscriptions). -```typescript -const markJobOutdated = useMutation(api.admin.markJobOutdated); + + There is no email to the submitter and no rejection reason field. A rejected job is simply removed from the queue and deleted by the retention cron 14 days after it was created. + -await markJobOutdated({ jobId }); -``` +## `markJobOutdated` -**Authentication:** Admin or Moderator role required +Closes a listing without destroying anything. -**Parameters:** -- `jobId` - ID of job to mark outdated +| | | +|---|---| +| Kind | `mutation` | +| Auth | Moderator or admin | -**Returns:** Confirmation object. +| Arg | Type | Required | +|---|---|---| +| `jobId` | `Id<"jobs">` | Yes | -### `deleteJob` +**Returns:** `{ success: true }` -Permanently delete a job. +The job leaves the public list, and anyone tracking it keeps the record: their dashboard card shows `availability: "closed"` with `closureReason: "outdated"`. The retention cron deletes outdated jobs 30 days after their creation time — and applies the same status automatically to approved jobs older than 14 days. -```typescript -const deleteJob = useMutation(api.admin.deleteJob); +Sends no email. -await deleteJob({ jobId }); -``` +## `deleteJob` + +Permanently deletes the job document. + +| | | +|---|---| +| Kind | `mutation` | +| Auth | Moderator or admin | + +| Arg | Type | Required | +|---|---|---| +| `jobId` | `Id<"jobs">` | Yes | + +**Returns:** `{ success: true }` -**Authentication:** Admin or Moderator role required +Tracked applications pointing at the job are not deleted; they fall back to their `jobSnapshot` and render as closed with `closureReason: "removed"`. Sends no email. -**Parameters:** -- `jobId` - ID of job to delete + + Unlike the retention cron, this mutation does not clean up the job's `jobNotificationDeliveries` rows, so those delivery receipts are left orphaned. They are harmless (deduplication keys for a job that no longer exists) but they are never reclaimed unless the affected user deletes their account. Prefer `markJobOutdated` for ordinary closures. + -**Returns:** Confirmation object. +## Where moderation happens -Deleted jobs are removed from listings; tracked applications remain available in user dashboards as closed entries. +| Surface | Function | +|---|---| +| Pending queue and role check on `/dashboard` | `dashboard.getDashboardData` | +| Approved listings panel | `admin.getApprovedJobs` | +| Approve / Reject buttons | `admin.updateJobStatus` | +| Mark outdated | `admin.markJobOutdated` | +| Delete | `admin.deleteJob` | +| Staff email when a job is submitted | `internal.notifications.sendNewSubmissionStaffEmail`, scheduled by `jobs.postJob` | diff --git a/content/docs/api-reference/bookmarks.mdx b/content/docs/api-reference/bookmarks.mdx index 32aae9e..f044f6a 100644 --- a/content/docs/api-reference/bookmarks.mdx +++ b/content/docs/api-reference/bookmarks.mdx @@ -1,87 +1,191 @@ --- title: Bookmarks API -description: Convex functions for bookmarking jobs +description: Convex functions for saving jobs and tracking application progress --- -# Bookmarks API +A bookmark and a tracked application are the same record: one row in the `trackedApplications` table, created the moment a user saves a job and carrying that job through `saved → applied → interviewing → offer → accepted`. All of it lives in `convex/bookmarks.ts`; the status rules it enforces live in `convex/applicationIntegrity.ts`. Ownership is checked on every write by comparing `userId` against the Clerk identity, so a bookmark id from another user is rejected rather than ignored. -Functions for saving and managing bookmarked jobs. + + The dashboard does not call the queries on this page. It reads bookmarks, role and pending jobs in a single query, `dashboard.getDashboardData` (`convex/dashboard.ts`), and uses the mutations here for writes. `getUserBookmarks` and `getBookmarkStatus` still exist and still work. + -## Queries +## `toggleBookmark` -### `getUserBookmarks` +Saves a job, or un-saves it — but only while there is nothing to lose. -Get all bookmarks for the current user. +| | | +|---|---| +| Kind | `mutation` | +| Auth | Authenticated | -```typescript -const bookmarks = useQuery(api.bookmarks.getUserBookmarks); -``` +| Arg | Type | Required | +|---|---|---| +| `jobId` | `Id<"jobs">` | Yes | -**Authentication:** Required +**Returns:** `{ bookmarked: boolean, tracked: boolean }` -**Returns:** Array of tracked applications with populated job data and availability metadata. +| Return | Meaning | +|---|---| +| `{ bookmarked: true, tracked: false }` | A new row was created with status `saved` | +| `{ bookmarked: false, tracked: false }` | An untouched `saved` row was deleted | +| `{ bookmarked: true, tracked: true }` | Nothing was deleted: the row has progress, so it was kept | -### `getBookmarkStatus` +A row counts as having progress when its `status` is anything other than `saved`, or when its `notes` contain non-whitespace text. In that case the toggle is a no-op and returns `tracked: true` so the caller can explain why the job stayed saved — deleting an application history from a one-click toggle on a list page is never correct. Removing a tracked application is a separate, explicit action: the "Remove from tracker" button on the dashboard card, which calls `removeTrackedApplication`. -Check if a job is bookmarked by the current user. +New rows are created with a `jobSnapshot` (a copy of the job's title, company, location, url, creation time, tags and role level) and a `statusHistory` seeded with a single `saved` entry. + +**Throws:** `Not authenticated`, `Job not found`. ```typescript -const status = useQuery(api.bookmarks.getBookmarkStatus, { jobId }); +const toggleBookmark = useMutation(api.bookmarks.toggleBookmark); + +const result = await toggleBookmark({ jobId: job._id }); +if (result.bookmarked && result.tracked) { + // Kept: this job is being tracked on the dashboard. +} ``` -**Authentication:** Required +## `updateBookmarkStatus` + +The mutation that powers application tracking. It writes the status, optionally the notes, and appends to `statusHistory` whenever the status actually changes. -**Parameters:** -- `jobId` - ID of the job to check +| | | +|---|---| +| Kind | `mutation` | +| Auth | Authenticated, and must own the row | -**Returns:** Object with `bookmarked` boolean. +| Arg | Type | Required | Notes | +|---|---|---|---| +| `bookmarkId` | `Id<"trackedApplications">` | Yes | | +| `status` | `"saved" \| "applied" \| "interviewing" \| "offer" \| "rejected" \| "accepted"` | Yes | Must be the current status or an allowed transition | +| `notes` | `string` | No | Trimmed, max 2000 characters. Omitting it leaves existing notes alone | -## Mutations +**Returns:** `{ success: true }` -### `toggleBookmark` +**Throws:** -Add or remove a bookmark. +| Error | Cause | +|---|---| +| `Not authenticated` | No Clerk identity | +| `Bookmark not found` | No row with that id | +| `Unauthorized` | The row belongs to another user | +| `Invalid status transition from X to Y.` | Not in the transition table below | +| `Notes must be 2000 characters or fewer.` | `notes` exceeds `MAX_NOTES_LENGTH` after trimming | + +Passing the current status is always allowed — that is how a notes-only edit is saved, and it does not append a history entry. ```typescript -const toggleBookmark = useMutation(api.bookmarks.toggleBookmark); +const updateBookmarkStatus = useMutation(api.bookmarks.updateBookmarkStatus); -await toggleBookmark({ jobId }); +await updateBookmarkStatus({ bookmarkId, status: "applied" }); +await updateBookmarkStatus({ bookmarkId, status: "applied", notes: "Phone screen booked" }); ``` -**Authentication:** Required +### Allowed status transitions -**Parameters:** -- `jobId` - ID of the job to bookmark/unbookmark +From `allowedStatusTransitions` in `convex/applicationIntegrity.ts`. Forward progress, plus a way back out of the two end states so a mis-click on Rejected or Accepted is recoverable without deleting the record. -**Returns:** Object with `bookmarked` boolean. +| From | Allowed next statuses | +|---|---| +| `saved` | `applied`, `interviewing`, `rejected` | +| `applied` | `interviewing`, `offer`, `rejected` | +| `interviewing` | `offer`, `rejected` | +| `offer` | `accepted`, `rejected` | +| `rejected` | `saved`, `applied`, `interviewing`, `offer` | +| `accepted` | `offer`, `rejected` | -### `removeTrackedApplication` + saved : toggleBookmark + saved --> applied + saved --> interviewing + saved --> rejected + applied --> interviewing + applied --> offer + applied --> rejected + interviewing --> offer + interviewing --> rejected + offer --> accepted + offer --> rejected + accepted --> offer + accepted --> rejected + rejected --> saved + rejected --> applied + rejected --> interviewing + rejected --> offer +`} +/> -Remove a tracked application from the dashboard. +Note what is not there: `saved` cannot jump straight to `offer` or `accepted`, `interviewing` cannot fall back to `applied`, and nothing can move to `saved` except from `rejected`. -```typescript -const removeTrackedApplication = useMutation( - api.bookmarks.removeTrackedApplication -); +The dashboard's status dropdown is built from the same table — `DashboardJobCard` imports `getAllowedStatusTransitions` directly from `convex/applicationIntegrity.ts`, so the menu only ever offers transitions the server will accept. -await removeTrackedApplication({ bookmarkId }); -``` +## `removeTrackedApplication` -**Authentication:** Required -**Parameters:** -- `bookmarkId` - Tracked application ID +Deletes a tracked application outright, including its status history and notes. This is the "Remove from tracker" button on the dashboard card, and it is the only way to delete a row that has progress on it. -## Usage Example +| | | +|---|---| +| Kind | `mutation` | +| Auth | Authenticated, and must own the row | -```tsx -function BookmarkButton({ jobId }) { - const bookmarkStatus = useQuery(api.bookmarks.getBookmarkStatus, { jobId }); - const toggleBookmark = useMutation(api.bookmarks.toggleBookmark); +| Arg | Type | Required | +|---|---|---| +| `bookmarkId` | `Id<"trackedApplications">` | Yes | - return ( - - ); -} -``` +**Returns:** `{ success: true }` + +**Throws:** `Not authenticated`, `Bookmark not found`, `Unauthorized`. + +## `getBookmarkStatus` + +| | | +|---|---| +| Kind | `query` | +| Auth | Anonymous allowed | + +| Arg | Type | Required | +|---|---|---| +| `jobId` | `Id<"jobs">` | Yes | + +**Returns:** `{ bookmarked: boolean }`. Signed-out callers always get `{ bookmarked: false }` rather than an error. + +The job list does not need this query: `jobs.listApprovedJobs` already returns an `isBookmarked` flag per job for authenticated callers. + +## `getUserBookmarks` + +| | | +|---|---| +| Kind | `query` | +| Auth | Anonymous allowed — returns `[]` when signed out | +| Args | none | + +**Returns:** an array, newest first, of: + +| Field | Type | Notes | +|---|---|---| +| `id` | `Id<"trackedApplications">` | Pass this as `bookmarkId` to the mutations | +| `status` | application status | | +| `notes` | `string \| null` | | +| `job` | object | `id`, `title`, `company`, `location`, `url`, `createdAt`, `tags`, `roleLevel`, `availability`, `closureReason` | + +`job` is resolved in one of three ways: + +| Source job | `availability` | `closureReason` | +|---|---|---| +| Still exists, status is not `outdated` | `active` | `null` | +| Still exists, status is `outdated` | `closed` | `outdated` | +| Deleted, but the row has a `jobSnapshot` | `closed` | `removed` | + +A row whose job was deleted **and** which has no `jobSnapshot` is dropped from the result. `dashboard.getDashboardData` returns bookmarks in exactly this shape. + +## `backfillTrackedApplicationSnapshots` + +| | | +|---|---| +| Kind | `internalMutation` — not callable from the client | +| Args | none | + +Fills in `jobSnapshot` for older rows created before snapshots existed, skipping rows that already have one and rows whose job is already gone. Returns `{ updatedCount }`. Run it from the Convex dashboard or CLI, not from the app. diff --git a/content/docs/api-reference/email-subscriptions.mdx b/content/docs/api-reference/email-subscriptions.mdx new file mode 100644 index 0000000..e68791f --- /dev/null +++ b/content/docs/api-reference/email-subscriptions.mdx @@ -0,0 +1,156 @@ +--- +title: Email Subscriptions API +description: Convex functions for job notification subscriptions and the emails they send +--- + +Users can ask to be emailed whenever a job matching their role levels is approved. The subscription itself lives in `convex/emailSubscriptions.ts` and is managed from `/profile`; the sending lives in `convex/notifications.ts` as internal actions that go through the `@convex-dev/resend` component configured in `convex/resend.ts`. Nothing here is called directly by a user to send mail — emails are only ever a side effect of submitting a job or of a moderator approving one. + +## `getMySubscription` + +| | | +|---|---| +| Kind | `query` | +| Auth | Anonymous allowed | +| Args | none | + +**Returns:** `null` when signed out or when the user has never subscribed, otherwise `{ isActive, roleFilters }`. The email address and the unsubscribe token are deliberately not returned. + +## `subscribe` + +Creates the subscription, or reactivates and updates an existing one. + +| | | +|---|---| +| Kind | `mutation` | +| Auth | Authenticated | + +| Arg | Type | Required | Notes | +|---|---|---|---| +| `roleFilters` | `Array<"intern" \| "graduate" \| "earlyCareer">` | Yes | De-duplicated server-side. An empty array means every role level | + +**Returns:** `{ success: true, isActive: true, roleFilters }` + +The email address is taken from the Clerk identity token, never from an argument, so a user can only ever subscribe their own verified address. On an existing row the `unsubscribeToken` is deliberately left unchanged, which keeps unsubscribe links already sitting in inboxes valid. + +**Throws:** `Not authenticated`; `Email address is required to manage notifications` when the identity carries no email. + +```typescript +const subscribe = useMutation(api.emailSubscriptions.subscribe); +await subscribe({ roleFilters: ["intern", "graduate"] }); +``` + +## `updateRoleFilters` + +| | | +|---|---| +| Kind | `mutation` | +| Auth | Authenticated | + +| Arg | Type | Required | +|---|---|---| +| `roleFilters` | `Array<"intern" \| "graduate" \| "earlyCareer">` | Yes | + +**Returns:** `{ success: true, roleFilters }`. Does not change `isActive` — an inactive subscription keeps its filters and stays inactive. + +**Throws:** `Not authenticated`, `Subscription not found`. + +## `unsubscribeFromProfile` + +| | | +|---|---| +| Kind | `mutation` | +| Auth | Authenticated | +| Args | none | + +Sets `isActive: false` on the caller's subscription. The row, the token and the filters are kept, so re-subscribing later restores the same settings. + +**Returns:** `{ success: true }` — also when there was no subscription at all. + +**Throws:** `Not authenticated`. + +## `unsubscribeByToken` + +Powers the one-click unsubscribe link in every notification email, at `/unsubscribe?token=...`. + +| | | +|---|---| +| Kind | `mutation` | +| Auth | Anonymous — this runs for people who are not signed in | + +| Arg | Type | Required | +|---|---|---| +| `token` | `string` | Yes | + +**Returns:** `{ success: true }` when a subscription matched the token (already-inactive rows also return `true`), `{ success: false }` when the token is empty or unknown. It throws nothing, and it never reveals whose subscription a token belongs to. + +Tokens are `crypto.randomUUID()` values stored in `emailSubscriptions.unsubscribeToken` and looked up through the `by_unsubscribeToken` index. + +## `getActiveSubscribersForRole` + +| | | +|---|---| +| Kind | `internalQuery` — not callable from the client | + +| Arg | Type | Required | +|---|---|---| +| `roleLevel` | `"intern" \| "graduate" \| "earlyCareer"` | No | + +Reads active subscriptions through the `by_isActive` index and keeps those whose `roleFilters` are empty or contain `roleLevel`. When `roleLevel` is omitted — a job with no role level — every active subscriber matches. + +**Returns:** `{ userId, email, unsubscribeToken }[]`. + +## Sending actions + +Both live in `convex/notifications.ts`, are `internalAction`s, and are scheduled by other functions rather than called from the client. Both return a result object instead of throwing, so a misconfigured mailer degrades to a log line rather than a failed mutation. + +### `sendNewSubmissionStaffEmail` + +Scheduled by [`jobs.postJob`](/docs/api-reference/jobs) with the new `jobId`. Emails every address in `STAFF_NOTIFICATION_EMAILS` with the job's details and a link to `/dashboard`. + +**Returns:** `{ success: false, reason }` with `reason` of `missing_staff_recipients`, `missing_email_from` or `job_not_found`; otherwise `{ success, attemptedCount, sentCount }` where `success` is true if at least one send was enqueued. + +### `sendJobApprovedUserEmails` + +Scheduled by [`admin.updateJobStatus`](/docs/api-reference/admin) when a job moves into `approved` from any other status. + +1. Re-reads the job and stops unless it is still `approved`. +2. Loads matching subscribers via `getActiveSubscribersForRole`. +3. For each subscriber, checks `jobNotificationDeliveries` through the `by_jobId_userId` index and skips anyone already emailed about this job. +4. Sends the email, then writes a `jobNotificationDeliveries` row with `sentAt`. + +**Returns:** `{ success: false, reason }` with `reason` of `missing_email_from`, `site_url_not_configured` or `job_not_found_or_not_approved`; otherwise `{ success: true, attemptedCount, sentCount, dedupedCount }`. + +A per-subscriber failure is logged and the loop continues, so one bad address cannot block the rest of the batch. Because the delivery row is only written after a successful send, a failed send will be retried the next time the action runs for that job. + +### Supporting internal functions + +| Function | Kind | Purpose | +|---|---|---| +| `getJobForStaffNotification` | `internalQuery` | Job fields for the staff email; returns `null` if the job is gone | +| `getJobForUserNotification` | `internalQuery` | Job fields for the subscriber email; returns `null` unless the job is still `approved` | +| `hasJobNotificationDelivery` | `internalQuery` | Deduplication check for one job and user | +| `createJobNotificationDelivery` | `internalMutation` | Records a successful send | + +## Configuration + +These are Convex deployment environment variables, set per deployment (dev and prod are separate). Names only — see [Deployment](/docs/technical/deployment). + +| Variable | Used for | +|---|---| +| `RESEND_API_KEY` | Credentials for the Resend component | +| `RESEND_TEST_MODE` | Whether sends are real. **Defaults to `true`** when unset | +| `EMAIL_FROM` | Sender address; without it both actions abort | +| `STAFF_NOTIFICATION_EMAILS` | Comma-separated staff recipients, trimmed and de-duplicated | +| `NEXT_PUBLIC_SITE_URL` | Base for the dashboard, browse and unsubscribe links; a trailing slash is stripped | +| `EMAIL_REPLY_TO` | Optional comma-separated reply-to addresses | + + + `convex/resend.ts` is safe by default: test mode is on unless `RESEND_TEST_MODE` is explicitly `false` (`0`/`false`/`no` are accepted, as are `1`/`true`/`yes` for on; anything unrecognised falls back to on). In test mode no mail reaches real inboxes, so a production deployment missing this variable silently sends nothing. + + +Emails are plain-text plus HTML, carry a no-reply notice, and the subscriber email always includes its unsubscribe link. + +## Related + +- User-facing walkthrough: [Email Notifications](/docs/user-guide/email-notifications) +- Table and index definitions: [Database Schema](/docs/technical/database-schema) diff --git a/content/docs/api-reference/jobs.mdx b/content/docs/api-reference/jobs.mdx index 033bd22..7427e86 100644 --- a/content/docs/api-reference/jobs.mdx +++ b/content/docs/api-reference/jobs.mdx @@ -1,17 +1,41 @@ --- title: Jobs API -description: Convex functions for job listings +description: Convex functions for listing, filtering and submitting job postings --- -# Jobs API +Everything a job posting does — being listed, being submitted, aging out — is in `convex/jobs.ts`, with submission validation split into `convex/jobSubmissionSecurity.ts` and role-level inference into `convex/jobHelpers.ts`. Two rules shape the whole module: the public list only ever contains jobs whose `status` is `approved`, and everything a user submits starts at `pending` and stays invisible until a moderator approves it through the [Admin API](/docs/api-reference/admin). -Functions for managing job listings. +## `listApprovedJobs` -## Queries +The query behind the home page listing. -### `listApprovedJobs` +| | | +|---|---| +| Kind | `query` | +| Auth | Anonymous allowed | -Get all approved job listings. +| Arg | Type | Required | Behaviour | +|---|---|---|---| +| `searchTerm` | `string` | No | Case-insensitive substring match against title, company or location | +| `roleLevel` | `"intern" \| "graduate" \| "earlyCareer"` | No | Exact match | +| `location` | `string` | No | Exact match on the trimmed location string, not a substring | + +**Returns:** an array, newest first, of: + +| Field | Type | Notes | +|---|---|---| +| `_id` | `Id<"jobs">` | | +| `_creationTime` | `number` | Convex-managed epoch ms | +| `title` | `string` | | +| `company` | `string` | | +| `location` | `string` | `""` when unset | +| `url` | `string` | `""` when unset | +| `status` | `string` | Always `"approved"` here | +| `tags` | `string[]` | `[]` when unset | +| `roleLevel` | role level or `undefined` | | +| `isBookmarked` | `boolean` | Always `false` for signed-out callers | + +`roleLevel` and `location` are served by indexes — the query picks `by_status_roleLevel_location`, `by_status_roleLevel`, `by_status_location` or `by_status` depending on which filters are present. `searchTerm` is applied in memory after the index read, so it never widens the set beyond what the other filters returned. ```typescript const jobs = useQuery(api.jobs.listApprovedJobs, { @@ -21,72 +45,139 @@ const jobs = useQuery(api.jobs.listApprovedJobs, { }); ``` -**Returns:** Array of approved jobs, sorted by creation date (newest first). - -**Parameters:** -- `searchTerm` - Optional text search over title/company/location -- `roleLevel` - Optional role level (`intern`, `graduate`, `earlyCareer`) -- `location` - Optional exact location filter +## `getApprovedJobFilterOptions` -### `getApprovedJobFilterOptions` +| | | +|---|---| +| Kind | `query` | +| Auth | Anonymous allowed | +| Args | none | -Get available location filter values from approved jobs. +**Returns:** `{ locations: string[] }` — the distinct, trimmed, non-empty locations across approved jobs, sorted alphabetically. Populates the location dropdown so it can never offer a filter value that matches nothing. -```typescript -const options = useQuery(api.jobs.getApprovedJobFilterOptions); -``` +## `postJob` -**Returns:** `{ locations: string[] }` +Submits a job for moderation. The record is inserted with `status: "pending"` and `submitterUserId` set to the caller's Clerk id; it does not appear in `listApprovedJobs` until a moderator approves it. -## Mutations +| | | +|---|---| +| Kind | `mutation` | +| Auth | Authenticated | -### `postJob` +| Arg | Type | Required | Limit | +|---|---|---|---| +| `title` | `string` | Yes | 120 characters after trimming | +| `company` | `string` | Yes | 120 characters after trimming | +| `location` | `string` | Yes | 120 characters after trimming | +| `url` | `string` | Yes | 2048 characters, `http`/`https` only | +| `roleLevel` | `"intern" \| "graduate" \| "earlyCareer"` | Yes | | +| `tags` | `string[]` | No | Up to 10 tags, 32 characters each | -Submit a new job for review. +**Returns:** `{ success: true, jobId, message }`, where `message` is the reviewed-within-24-hours confirmation shown in the submit modal. ```typescript const postJob = useMutation(api.jobs.postJob); -await postJob({ - title: "Junior Developer", +const result = await postJob({ + title: "Junior Frontend Developer", company: "Acme Inc", - roleLevel: "earlyCareer", location: "Remote", - url: "https://...", + url: "https://acme.example/careers/123", + roleLevel: "earlyCareer", tags: ["React", "TypeScript"], }); ``` -**Authentication:** Required +### Validation + +`validateAndNormalizeJobSubmission` runs before anything is written. All limits come from the constants at the top of `convex/jobSubmissionSecurity.ts`. + +Text fields (`title`, `company`, `location`) are trimmed, must be non-empty, must be within their length cap, and must contain no ASCII control characters. + +The URL is checked harder, because it is a link the whole site will click: + +| Check | Error thrown | +|---|---| +| Non-empty after trimming | `URL is required` | +| At most 2048 characters | `URL must be 2048 characters or fewer` | +| No control characters | `URL contains invalid characters` | +| Parses as a URL | `Please enter a valid URL` | +| Scheme is `http:` or `https:` | `URL must start with http:// or https://` | +| No embedded credentials | `URL must not include username or password` | +| Non-empty hostname | `URL must include a valid hostname` | +| Not a local or private host | `Local or private network URLs are not allowed` | + +The private-host check rejects `localhost`, `::1`, any `.local` or `.internal` suffix, IPv6 unique-local (`fc`/`fd`) and link-local (`fe8`–`feb`) prefixes, and the IPv4 ranges `0.0.0.0/8`, `10/8`, `127/8`, `169.254/16`, `172.16–172.31`, and `192.168/16`. The stored `url` is the normalized `URL.toString()` output, not the raw input. + +Tags are rejected wholesale if there are more than 10 or if any contains control characters; individually they are trimmed, dropped when empty, capped at 32 characters, and de-duplicated case-insensitively (the first casing seen wins). Errors: `You can add up to 10 tags`, `Tags contain invalid characters`, `Each tag must be 32 characters or fewer`. + + + `src/lib/postJobError.ts` maps these server messages to the friendlier copy shown in the submit modal, so changing an error string here means updating that mapping too. It is covered by `tests/lib/postJobError.test.ts` and the validator itself by `tests/convex/jobSubmissionSecurity.test.ts`. + -**Parameters:** -| Field | Type | Required | -|-------|------|----------| -| title | string | Yes | -| company | string | Yes | -| roleLevel | `"intern" \| "graduate" \| "earlyCareer"` | Yes | -| location | string | Yes | -| url | string | Yes | -| tags | string[] | No | +### Rate limits -**Returns:** Object with `success`, `jobId`, and `message`. +Counted per Clerk user over the `by_submitterUserId` index, across every submission regardless of its status. -**Submission security controls:** -- Strict server-side input validation and normalization. -- URL safety checks (`http`/`https`, no embedded credentials, no local/private-network hosts). -- Tag sanitization and limits. -- Per-user submission rate limiting. +| Window | Maximum | Error thrown | +|---|---|---| +| 1 hour | 6 submissions | `Rate limit reached. Please wait before submitting another job.` | +| 24 hours | 20 submissions | `Daily submission limit reached. Please try again tomorrow.` | -## Internal Functions +### Side effects + +1. Any tag that is not already in the `tags` table is inserted, so the tag filter list grows with real submissions. +2. `internal.notifications.sendNewSubmissionStaffEmail` is scheduled with the new `jobId`. Scheduling failures are logged and swallowed — a broken email configuration never fails a submission. See [Email Subscriptions API](/docs/api-reference/email-subscriptions). + +**Throws:** `Not authenticated`, plus any validation or rate-limit error above. + +## Role levels + +`roleLevel` is one of `intern`, `graduate` or `earlyCareer`, and it drives both the filters and the per-role email notification filters. + +`inferRoleLevelFromTitle` in `convex/jobHelpers.ts` derives it from the job title, lowercased: + +| Result | Triggered by a title containing | +|---|---| +| `intern` | `internship`, `intern`, `co-op`, `co op`, `placement`, `trainee`, `apprentice` | +| `graduate` | `graduate`, `new grad` | +| `earlyCareer` | anything else (the fallback) | + + + Inference is **not** applied to new submissions. `postJob` requires an explicit `roleLevel` from the submitter; `inferRoleLevelFromTitle` is only used by the `backfillJobRoleLevelsFromTitle` migration below. + + +## Internal functions + +Not callable from the client — they have no entry in the public `api` object and must be run from the Convex dashboard, the CLI, or the cron scheduler. ### `deleteOldJobs` -Cron job that cleans up old listings: +| | | +|---|---| +| Kind | `internalMutation` | +| Schedule | Daily at 00:00 UTC, registered as `cleanup old jobs` in `convex/crons.ts` | +| Args | none | + +Per run, in order: + +| Step | Rule | +|---|---| +| Delete stale submissions | `pending` and `rejected` jobs created more than 14 days ago | +| Delete expired closures | `outdated` jobs created more than 30 days ago | +| Close aged listings | `approved` jobs created more than 14 days ago become `outdated` | -- Pending/rejected jobs older than 2 weeks: deleted -- Approved jobs older than 2 weeks: marked as "outdated" -- Outdated jobs older than 30 days: deleted +Expired `outdated` jobs are deleted before new ones are marked, so a job is never marked outdated and hard-deleted in the same run. Deleting a job also deletes its `jobNotificationDeliveries` rows. Each step takes at most 100 jobs; if any step fills its batch the mutation reschedules itself immediately to work through the backlog, which keeps each transaction inside Convex's limits. + +**Returns:** `{ deletedCount, markedOutdatedCount }`. + +A deleted job does not vanish from a user's dashboard — the tracked application falls back to its `jobSnapshot` and is shown as closed. See [Bookmarks API](/docs/api-reference/bookmarks). ### `backfillJobRoleLevelsFromTitle` -Backfills missing `roleLevel` values from job title keywords. +| | | +|---|---| +| Kind | `internalMutation` | +| Args | none | + +One-off migration: sets `roleLevel` on every job that is missing one, using `inferRoleLevelFromTitle`. Jobs that already have a role level are skipped. Returns `{ updatedCount }`. diff --git a/content/docs/api-reference/meta.json b/content/docs/api-reference/meta.json index 861b238..2375fed 100644 --- a/content/docs/api-reference/meta.json +++ b/content/docs/api-reference/meta.json @@ -1,4 +1,4 @@ { "title": "API Reference", - "pages": ["jobs", "bookmarks", "profiles", "admin"] + "pages": ["jobs", "bookmarks", "profiles", "email-subscriptions", "admin"] } diff --git a/content/docs/api-reference/profiles.mdx b/content/docs/api-reference/profiles.mdx index 53d0674..70c03bc 100644 --- a/content/docs/api-reference/profiles.mdx +++ b/content/docs/api-reference/profiles.mdx @@ -1,79 +1,134 @@ --- title: Profiles API -description: Convex functions for user profiles +description: Convex functions for the signed-in user's profile, role and account deletion --- -# Profiles API +Clerk owns identity (email, password, OAuth). Convex stores one companion row per user in the `profiles` table holding just two things the app controls: an editable `fullName` and a `role` that gates moderation. The functions are split across two modules: `convex/users.ts` (session bootstrap) and `convex/profiles.ts` (read, rename, delete). Every function keys off `identity.subject`, the Clerk user id — a caller can never read or write another user's profile through this API. -Functions for managing user profile information. + + There is no public profile: no `displayName`, `bio`, `links` or avatar fields exist, and there is no function to fetch another user's profile. The full field list is in [Database Schema](/docs/technical/database-schema). + -## Queries +## `users.currentUser` -### `get` +Session bootstrap query used by the `useAuth` hook (`src/hooks/useAuth.ts`). -Get the current user's profile. +| | | +|---|---| +| Kind | `query` | +| Auth | Anonymous allowed — returns `null` when signed out | +| Args | none | + +**Returns:** `null`, or `{ id, email, profile }` where `id` is the Clerk user id, `email` comes from the Clerk identity token, and `profile` is the raw `profiles` document (or `null` if the row has not been created yet). ```typescript -const profile = useQuery(api.profiles.get); +const user = useQuery(api.users.currentUser); +// user?.profile?.role === "admin" ``` -**Authentication:** Required +## `users.ensureProfile` + +Creates the `profiles` row the first time a user signs in. Idempotent: if a row already exists it is returned untouched. + +| | | +|---|---| +| Kind | `mutation` | +| Auth | Authenticated | +| Args | none | -**Returns:** Profile object or null if not created. +**Returns:** the `profiles` document. -### `getByUserId` +New rows are inserted with `fullName` taken from the Clerk identity `name` (or omitted) and `role: "user"`. Nothing in the API can escalate a role — promoting someone to `moderator` or `admin` is a manual edit in the Convex dashboard. -Get a profile by user ID (for viewing other users). +**Throws:** `Not authenticated`. ```typescript -const profile = useQuery(api.profiles.getByUserId, { userId }); +const ensureProfile = useMutation(api.users.ensureProfile); +await ensureProfile(); ``` -**Parameters:** -- `userId` - Clerk user ID +`useAuth` calls this once per session after Convex reports the user as authenticated, so pages generally do not need to call it themselves. + +## `profiles.getUserProfile` + +The profile page's read (`src/components/ProfilePage.tsx`). + +| | | +|---|---| +| Kind | `query` | +| Auth | Authenticated | +| Args | none | + +**Returns** one of: + +| Shape | When | +|---|---| +| `{ id, email, fullName, role }` | Signed in and the profile row exists. `fullName` is `null` when unset. | +| `{ error: "Not authenticated" }` | No Clerk identity on the request | +| `{ error: "Profile not found" }` | Signed in, but `users.ensureProfile` has not run yet | + + + This query returns error objects instead of throwing, so callers must check for the `error` key before reading `role`. The other profile functions throw. + -**Returns:** Public profile data. +## `profiles.updateUserName` -## Mutations +| | | +|---|---| +| Kind | `mutation` | +| Auth | Authenticated | -### `update` +| Arg | Type | Required | Notes | +|---|---|---|---| +| `fullName` | `string` | Yes | Trimmed server-side | -Update the current user's profile. +**Returns:** `{ success: true }`, or `{ error: "Name is required" }` when the trimmed value is empty, or `{ error: "Profile not found" }`. + +**Throws:** `Not authenticated`. ```typescript -const updateProfile = useMutation(api.profiles.update); - -await updateProfile({ - displayName: "Jane Developer", - bio: "Frontend developer learning React", - links: { - github: "https://github.com/jane", - linkedin: "https://linkedin.com/in/jane", - }, -}); +const updateUserName = useMutation(api.profiles.updateUserName); +await updateUserName({ fullName: "Jane Developer" }); ``` -**Authentication:** Required +There is no length cap on `fullName` in the mutation. -**Parameters:** -| Field | Type | Required | -|-------|------|----------| -| displayName | string | No | -| bio | string | No | -| links | object | No | +## `profiles.deleteAccount` -**Returns:** Updated profile object. +Deletes everything this app stores about the caller. It does **not** delete the Clerk user; the profile page calls this mutation first and then calls Clerk's `user.delete()`. -### `create` +| | | +|---|---| +| Kind | `mutation` | +| Auth | Authenticated | +| Args | none | -Create a profile for a new user (called automatically on first sign-in). +Deleted, in order: every `trackedApplications` row for the user, every `jobNotificationDeliveries` row for the user, the `emailSubscriptions` row (if any), and the `profiles` row (if any). -```typescript -const createProfile = useMutation(api.profiles.create); +**Returns:** -await createProfile({ - displayName: identity.name, -}); -``` +| Field | Type | Meaning | +|---|---|---| +| `success` | `boolean` | Always `true` when the mutation completes | +| `deletedTrackedApplicationsCount` | `number` | Tracked applications removed | +| `deletedNotificationDeliveriesCount` | `number` | Email delivery receipts removed | +| `deletedEmailSubscription` | `boolean` | Whether a subscription row existed | +| `deletedProfile` | `boolean` | Whether a profile row existed | + +**Throws:** `Not authenticated`. + + + Jobs the user submitted are not deleted. `jobs.submitterUserId` keeps the Clerk id of a deleted account until the retention cron removes the job (see [Jobs API](/docs/api-reference/jobs)). + + +## Roles + +`profiles.role` is the only authorization input in the backend. + +| Role | Can do | +|---|---| +| `user` | Browse, bookmark, track applications, submit jobs, manage email notifications | +| `moderator` | Everything above, plus every function in the [Admin API](/docs/api-reference/admin) | +| `admin` | Same permissions as `moderator`; the two are only distinguished in the UI | -**Authentication:** Required +The dashboard reads roles through `dashboard.getDashboardData`, which returns `userRole: { isAdmin, isModerator, userEmail, role }` in the same round trip as the rest of the page data. diff --git a/content/docs/architecture/authentication.mdx b/content/docs/architecture/authentication.mdx index 41b8812..68885a5 100644 --- a/content/docs/architecture/authentication.mdx +++ b/content/docs/architecture/authentication.mdx @@ -1,71 +1,145 @@ --- title: Authentication -description: How authentication works with Clerk and Convex +description: How Clerk sessions reach Convex, where roles actually live, and what enforces authorization --- -# Authentication +Clerk owns identity: sign-up, sign-in, OAuth and sessions. Convex owns authorization: every query and mutation that needs a user calls `ctx.auth.getUserIdentity()` and checks the caller's role itself. Roles live in the Convex `profiles` table, not in Clerk. Nothing is protected by middleware — `src/proxy.ts` attaches the Clerk session but guards no route — so authorization is enforced inside Convex functions, with page-level redirects as a convenience on top. -FirstDevJob uses Clerk for authentication, integrated with Convex for backend authorization. +## Sign-in flows -## Auth Providers +There are two entry points, both backed by Clerk. -Users can sign in with: +| Entry point | File | Flows offered | +|---|---|---| +| Modal, opened from the header and from "Post Job" while signed out | `src/components/AuthModal.tsx` | Email + password sign-in; email + password sign-up followed by a 6-digit email verification code; OAuth via `oauth_github` and `oauth_google` | +| Dedicated page at `/auth/login` | `src/app/auth/login/[[...rest]]/page.tsx` | Clerk's `` component, which renders whichever methods are enabled in the Clerk dashboard | -- **GitHub** - OAuth 2.0 -- **Google** - OAuth 2.0 -- **Email** - Magic link or password +The modal drives Clerk's hooks directly: `signIn.create({ identifier, password })` for sign-in, `signUp.create(...)` followed by `prepareEmailAddressVerification({ strategy: "email_code" })` and `attemptEmailAddressVerification({ code })` for sign-up, and `signIn.authenticateWithRedirect({ strategy, redirectUrl: "/auth/sso-callback", redirectUrlComplete: "/" })` for OAuth. `/auth/sso-callback` renders Clerk's `` and nothing else. -## Integration Architecture + +There is no magic-link flow. The email option is a password plus a one-time verification code sent at sign-up. The modal renders both a GitHub and a Google button, but whether a provider works depends on it being enabled in the Clerk dashboard — the repository cannot tell you which are switched on. + -``` -User → Clerk → JWT Token → Convex → Validate → Execute -``` +The login page reads a `redirect_url` query parameter and sanitizes it: anything that is not a same-origin path starting with a single `/` falls back to `/dashboard`. That check is what keeps the redirect from becoming an open redirect. -### Frontend Setup +## From sign-in to a Convex identity -The app is wrapped with `ClerkProvider`: +>A: password, verification code or OAuth + A->>K: Clerk sign-in + K-->>R: active session + R->>K: request JWT for template convex + K-->>R: signed JWT + R->>X: query or mutation with JWT attached + X->>K: fetch JWKS from CLERK_JWT_ISSUER_DOMAIN + X->>X: ctx.auth.getUserIdentity() + R->>X: useAuth effect calls api.users.ensureProfile + X->>X: insert profiles row, role user, if none exists + X-->>R: profile +`} +/> -```tsx - - - {children} - - -``` +Three pieces connect Clerk to Convex: + +1. `src/app/layout.tsx` wraps the tree in `` and then ``. When `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` is missing the app renders without either provider, which is what lets CI build without Clerk secrets. +2. `src/components/ConvexClientProvider.tsx` uses `ConvexProviderWithClerk` with Clerk's `useAuth`, so the Convex client always sends a current token. +3. `convex/auth.config.ts` declares one provider: `domain: process.env.CLERK_JWT_ISSUER_DOMAIN` and `applicationID: "convex"` — matching a Clerk JWT template named `convex`. + + +`CLERK_JWT_ISSUER_DOMAIN` is set on the **Convex** deployment, not in `.env.local` for Next.js, and its value is the issuer domain only — for example `https://clerk.example.com` — never the full JWKS URL. Convex dev and prod are separate deployments with separate environment variables, so a working dev setup says nothing about prod. Set prod with `bunx convex env set CLERK_JWT_ISSUER_DOMAIN --prod`. + + +### Profiles are created lazily -### Backend Validation +There is no Clerk webhook and no user sync job. `src/hooks/useAuth.ts` calls the `api.users.ensureProfile` mutation once per session after Convex reports the user as authenticated; the mutation inserts a `profiles` row with `userId: identity.subject`, `fullName` from the Clerk identity, and `role: "user"` only if no row exists. The hook guards with a ref and resets it if the call fails, so it retries on the next render pass rather than looping. -Convex functions access user identity: +A consequence worth knowing: a user can be authenticated with no profile row for a moment. `api.admin.checkUserRole` and `api.dashboard.getDashboardData` both handle a missing profile by reporting no privileges rather than throwing. + +## Roles and authorization ```typescript -export const myFunction = mutation({ - handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); +// convex/schema.ts +profiles: defineTable({ + userId: v.string(), + fullName: v.optional(v.string()), + role: v.union(v.literal("user"), v.literal("moderator"), v.literal("admin")), +}).index("by_userId", ["userId"]), +``` + +That field is the single source of truth. Roles are **not** stored in Clerk metadata and nothing syncs them. Every new profile is created as `user`, and there is no promotion UI or mutation — changing someone to `moderator` or `admin` means editing the `profiles` row in the Convex dashboard or through a one-off `bunx convex run` against an internal function. + + PUB["Public reads: jobs.listApprovedJobs, jobs.getApprovedJobFilterOptions, tags.getAllTags, emailSubscriptions.unsubscribeByToken"] + US["role: user"] --> PUB + US --> OWN["Own data: jobs.postJob, bookmarks.toggleBookmark, bookmarks.updateBookmarkStatus, bookmarks.removeTrackedApplication, profiles.updateUserName, profiles.deleteAccount, email subscription mutations"] + MO["role: moderator"] --> OWN + MO --> MOD["requireModOrAdmin: admin.getPendingJobs, admin.getApprovedJobs, admin.updateJobStatus, admin.markJobOutdated, admin.deleteJob"] + AD["role: admin"] --> MOD +`} +/> - if (!identity) { - throw new Error("Not authenticated"); - } +| Role | Can do | Enforced by | +|---|---|---| +| Anonymous | Browse approved jobs, read filter options and tags, unsubscribe with a token | Nothing — these functions do not require an identity | +| `user` | Everything above, plus post jobs, track applications, edit their own name, manage email notifications, delete their own data | `requireAuth(ctx)` or an inline `getUserIdentity()` check, plus a `userId` ownership comparison on each tracked application | +| `moderator` | Everything above, plus approve, reject, mark outdated and delete any job | `requireModOrAdmin(ctx)` in `convex/admin.ts` | +| `admin` | The same set as `moderator` today | `requireModOrAdmin(ctx)` accepts both roles | - // User is authenticated - const userId = identity.subject; +`admin` currently grants nothing beyond `moderator`. The only difference is cosmetic: `api.admin.checkUserRole` returns `isAdmin`, and `AdminSection` titles the panel "Admin Panel" instead of "Moderator Panel". There is no user-management surface in the app. + +Ownership checks are separate from role checks. `bookmarks.removeTrackedApplication` and `bookmarks.updateBookmarkStatus` load the row and throw `"Unauthorized"` when `bookmark.userId !== identity.subject`, so a valid session cannot touch another user's tracked applications. + +## Checking auth in a Convex function + +```typescript +import { mutation } from "./_generated/server"; +import { requireAuth } from "./auth"; + +export const example = mutation({ + args: {}, + handler: async (ctx) => { + const identity = await requireAuth(ctx); // throws "Not authenticated" + const userId = identity.subject; // the Clerk user id }, }); ``` -## Protected Routes +`convex/auth.ts` exports exactly two helpers: `requireAuth(ctx)`, which throws when there is no identity, and `getAuth(ctx)`, which returns `null` instead. Queries that should degrade gracefully for signed-out visitors — `jobs.listApprovedJobs`, `bookmarks.getUserBookmarks`, `dashboard.getDashboardData`, `emailSubscriptions.getMySubscription` — call `ctx.auth.getUserIdentity()` directly and return a safe empty result. + +## Route protection + + +`src/proxy.ts` — Next.js 16's replacement for `middleware.ts` — exports a bare `clerkMiddleware()` with no `createRouteMatcher` and no `auth.protect()` call. Its `config.matcher` only controls which requests the middleware *runs on*; it protects nothing. Do not treat a route as secured because the middleware runs there. + + +What actually gates the two signed-in pages: + +| Route | Mechanism | Behaviour when signed out | +|---|---|---| +| `/profile` | Server component calls Clerk's `auth()` in `src/app/profile/page.tsx` | `redirect("/auth/login?redirect_url=%2Fprofile")` before any HTML is sent | +| `/dashboard` | Client component effect in `src/app/dashboard/page.tsx` | `router.push("/auth/login?redirect_url=%2Fdashboard")` after hydration; renders a skeleton, then `null`, in the meantime | + +Neither redirect is a security boundary. `/dashboard`'s check runs in the browser, and the moderation panel it hosts is rendered from `getDashboardData`, which returns an empty `pendingJobs` array to non-moderators and a `userRole` of all-false. The real boundary is `requireModOrAdmin` inside `convex/admin.ts`: a crafted client call to `api.admin.updateJobStatus` throws `"Unauthorized"` regardless of what the UI shows. -Use Clerk's middleware for route protection: +Signing out is handled by Clerk's `signOut()` from the header, followed by a hard navigation to `/` to clear client state. -- `/admin/*` - Requires admin role -- `/profile/*` - Requires authentication -- `/` - Public access +## The one unauthenticated mutation -## User Roles +`api.emailSubscriptions.unsubscribeByToken` takes an opaque `unsubscribeToken` and requires no session, because unsubscribe links have to work from an inbox. The token is a `crypto.randomUUID()` generated when the subscription is first created and deliberately preserved across re-subscribes, so links already sitting in old emails keep working. An unknown token returns `{ success: false }` rather than an error. Every other subscription mutation goes through `requireAuth`. -| Role | Capabilities | -|------|-------------| -| User | Browse, bookmark, track applications | -| Moderator | Approve/reject job submissions | -| Admin | Full access, user management | +## Related pages -Roles are stored in Clerk metadata and synced to Convex. +- [Data Flow](/docs/architecture/data-flow) — how an authenticated request is served and what approval triggers +- [Admin API](/docs/api-reference/admin) — the moderation functions behind `requireModOrAdmin` +- [Deployment](/docs/technical/deployment) — where each Clerk variable has to be set diff --git a/content/docs/architecture/data-flow.mdx b/content/docs/architecture/data-flow.mdx index 5f833f2..d004d3e 100644 --- a/content/docs/architecture/data-flow.mdx +++ b/content/docs/architecture/data-flow.mdx @@ -1,67 +1,155 @@ --- title: Data Flow -description: How data moves through the application +description: How a request travels from a React component to Convex and back, and how a job moves through its lifecycle --- -# Data Flow +Every read in FirstDevJob is a live subscription and every write is a Convex mutation. Components call `useQuery` and `useMutation` from `convex/react` against the generated `api` object in `convex/_generated/api`; there is no fetch layer, no REST route and no client cache to invalidate. This page traces a read, a write, and the lifecycle a job follows from submission to deletion. -Understanding how data flows through FirstDevJob helps when debugging or adding features. +## Reads are subscriptions -## Request Types - -### Queries (Read Operations) - -Queries fetch data and automatically subscribe to updates: +`useQuery` opens a subscription over the Convex WebSocket. When any mutation changes data the query depends on, Convex recomputes it and pushes the new result to every subscribed client. ```typescript -// Component subscribes to job listings -const jobs = useQuery(api.jobs.listApproved); +// src/components/JobSearchWrapper.tsx +const filterOptions = useQuery(api.jobs.getApprovedJobFilterOptions, {}); -// Automatically updates when data changes +const jobs = useQuery(api.jobs.listApprovedJobs, { + searchTerm: debouncedSearchQuery.trim() || undefined, + roleLevel: selectedRoleLevel || undefined, + location: selectedLocation || undefined, +}); ``` -### Mutations (Write Operations) - -Mutations modify data and trigger subscription updates: +All three arguments are optional. `listApprovedJobs` picks the narrowest index that matches the arguments it was given — `by_status_roleLevel_location`, `by_status_roleLevel`, `by_status_location` or `by_status` — and only the free-text `searchTerm` is applied in memory, over title, company and location. + +The query also reads the caller's identity. When a request is authenticated it loads that user's tracked applications and returns an `isBookmarked` flag per job, so the list page does not need a second round trip. + +>V: useQuery(api.jobs.listApprovedJobs, filters) + V->>S: subscribe over WebSocket + S->>S: read jobs by index, attach isBookmarked + S-->>V: results + V-->>C: render + M->>S: useMutation(api.admin.updateJobStatus) + S->>S: requireModOrAdmin, patch status to approved + S-->>V: push recomputed results + V-->>C: re-render without refetching +`} +/> + +## Writes are mutations + +Mutations run as a single transaction on the backend. They validate arguments through `v.*` validators, re-check authorization server-side, and schedule any slow work rather than awaiting it. ```typescript -// Submit a new job -const submitJob = useMutation(api.jobs.submit); - -await submitJob({ - title: "Junior Developer", - company: "Acme Inc", - // ... +// src/components/PostJobModal.tsx +const postJob = useMutation(api.jobs.postJob); + +const result = await postJob({ + title: formData.title, + company: formData.company, + roleLevel: formData.roleLevel, + location: formData.location, + url: validatedUrl, + tags: formData.selectedTags, }); +// result: { success: true, jobId, message } ``` -## Authentication Flow +`postJob` does five things in order: require an authenticated identity, validate and normalize the submission, enforce per-user rate limits, insert the job with `status: "pending"`, and insert any new tag names into the `tags` table. It then schedules `internal.notifications.sendNewSubmissionStaffEmail` with `ctx.scheduler.runAfter(0, ...)` inside a `try`/`catch`, so a scheduling failure logs an error but never rolls back the submission. -1. User clicks Sign In -2. Clerk modal opens -3. User authenticates (OAuth or email) -4. Clerk returns JWT token -5. Convex validates token on each request -6. User identity available in backend functions +### Submission limits -## Job Lifecycle +| Limit | Value | Where | +|---|---|---| +| Submissions per user per hour | 6 | `MAX_SUBMISSIONS_PER_HOUR` in `convex/jobs.ts` | +| Submissions per user per day | 20 | `MAX_SUBMISSIONS_PER_DAY` in `convex/jobs.ts` | +| Title / company / location length | 120 characters each | `convex/jobSubmissionSecurity.ts` | +| URL length | 2048 characters | `convex/jobSubmissionSecurity.ts` | +| Tags per job | 10, each at most 32 characters | `convex/jobSubmissionSecurity.ts` | -``` -Submit → Pending → Approved → Listed - ↘ Rejected +Both rate limits are counted from the submitter's own jobs via the `by_submitterUserId` index, using `_creationTime`. URLs must parse, must be `http:` or `https:`, must not embed credentials, and must not point at localhost or a private network range. Tags are trimmed and de-duplicated case-insensitively. -After 2 weeks: -Approved → Outdated → Deleted (30 days) -Pending/Rejected → Deleted -``` +## Job lifecycle + + pending: postJob + pending --> approved: updateJobStatus + pending --> rejected: updateJobStatus + approved --> outdated: markJobOutdated + approved --> outdated: deleteOldJobs, 14 days after creation + pending --> [*]: deleteOldJobs, 14 days after creation + rejected --> [*]: deleteOldJobs, 14 days after creation + outdated --> [*]: deleteOldJobs, 30 days after creation + approved --> [*]: admin deleteJob +`} +/> + +`admin.deleteJob` works on a job in any state; the diagram shows it from `approved` because that is the common case. Every other threshold is measured from the job's `_creationTime`, not from the moment its status last changed. An approved job is therefore marked `outdated` on day 14 and hard-deleted on day 30 — roughly sixteen days as a visible-but-closed listing, not thirty. + +| Status | Visible on `/` | Aged out by the cron | +|---|---|---| +| `pending` | No | Deleted 14 days after creation | +| `rejected` | No | Deleted 14 days after creation | +| `approved` | Yes | Marked `outdated` 14 days after creation | +| `outdated` | No | Deleted 30 days after creation | + +## The daily cleanup + +`convex/crons.ts` runs `internal.jobs.deleteOldJobs` every day at 00:00 UTC. One run: + +1. Deletes stale `pending` and `rejected` jobs. +2. Deletes `outdated` jobs past the 30-day mark. This happens *before* step 3 so a job can never be marked outdated and hard-deleted in the same run. +3. Marks `approved` jobs older than 14 days as `outdated`. + +Each step takes at most `CLEANUP_BATCH_SIZE` (100) documents so the mutation stays inside Convex's transaction limits. If any step fills its batch, the mutation reschedules itself with `ctx.scheduler.runAfter(0, internal.jobs.deleteOldJobs, {})` and keeps draining the backlog. + +Cron deletions also remove the job's `jobNotificationDeliveries` rows, via the `by_jobId_userId` index. Note that the manual `admin.deleteJob` mutation deletes only the job document, so a job removed by a moderator can leave delivery rows behind; they are harmless — the table is only read to suppress duplicate emails for a job that no longer exists — but they are not cleaned up. + + +A user's tracked applications survive the job they point at. `toggleBookmark` stores a `jobSnapshot` (title, company, location, url, createdAt, tags, roleLevel) on the `trackedApplications` row. When the job document is gone, the dashboard falls back to that snapshot and shows the entry with `availability: "closed"` and `closureReason: "removed"`; a job that is merely `outdated` shows as `closed` with reason `"outdated"`. + + +## Approval fan-out + +Approving a job is the one write that triggers email to users. `api.admin.updateJobStatus` patches the status, then — only when the previous status was not already `approved` — schedules `internal.notifications.sendJobApprovedUserEmails`. + + B["patch job status"] + B --> C{"was it already approved?"} + C -->|"yes"| D["do nothing"] + C -->|"no"| E["schedule sendJobApprovedUserEmails"] + E --> F["getActiveSubscribersForRole"] + F --> G{"already in jobNotificationDeliveries?"} + G -->|"yes"| H["skip, count as deduped"] + G -->|"no"| I["resend.sendEmail"] + I --> J["insert jobNotificationDeliveries row"] +`} +/> + +`getActiveSubscribersForRole` returns every active subscription whose `roleFilters` is empty or contains the job's `roleLevel`; an empty filter list means "all role levels". The `jobNotificationDeliveries` table is the de-duplication ledger, so re-approving a job cannot email the same subscriber twice. The action returns `{ attemptedCount, sentCount, dedupedCount }`, and a failure for one subscriber is logged and skipped rather than aborting the loop. + +Details of the subscription functions are in the [Email Subscriptions API](/docs/api-reference/email-subscriptions); the moderation mutations are in the [Admin API](/docs/api-reference/admin). + +## Dashboard reads -## Real-time Updates +`/dashboard` hydrates from a single query, `api.dashboard.getDashboardData`, rather than composing several. It returns the user, their tracked applications with job data resolved, their role flags, the pending-jobs queue (only when the caller is a moderator or admin) and all tag names. When the caller is signed out it returns empty collections rather than throwing. -Convex automatically pushes updates to subscribed clients: +The one addition is `AdminSection`, which subscribes to `api.admin.getApprovedJobs` for its second tab — and passes `"skip"` instead of arguments when `userRole.isModerator` is false, so the query is never sent for an ordinary user. -1. Admin approves a job -2. Convex mutation updates database -3. All clients with active `listApproved` query receive update -4. UI re-renders with new data +## Deleting an account -No polling or manual refresh needed. +`api.profiles.deleteAccount` removes the caller's `trackedApplications`, `jobNotificationDeliveries` and `emailSubscriptions` rows and then the `profiles` row, returning counts for each. Jobs the user submitted are not deleted — they keep their `submitterUserId`, which no longer resolves to a profile. The `/profile` UI calls this mutation first and only then Clerk's `user.delete()`, so the Convex rows are gone before the identity that authorizes removing them. diff --git a/content/docs/architecture/overview.mdx b/content/docs/architecture/overview.mdx index c6f4ffd..e43bcfe 100644 --- a/content/docs/architecture/overview.mdx +++ b/content/docs/architecture/overview.mdx @@ -1,56 +1,104 @@ --- title: Architecture Overview -description: System architecture and design decisions +description: How the Next.js frontend, Convex backend, Clerk auth and Resend email fit together --- -# Architecture Overview +FirstDevJob is a Next.js 16 App Router frontend deployed on Vercel, backed by a Convex deployment that holds every table, query, mutation, scheduled action and cron job. Clerk handles sign-in and issues the JWT that Convex verifies on each request. Transactional email is sent from inside Convex through the `@convex-dev/resend` component. There is no separate API server: the browser talks to Convex directly over a WebSocket, and Next.js serves pages, the docs site and one search route. -FirstDevJob is built with a modern, serverless architecture optimized for real-time updates and developer experience. +## System diagram -## System Diagram +|"HTTP page loads"| APP + B -->|"sign in"| CLK + CLK -->|"session JWT"| B + B -->|"queries and mutations over WebSocket"| CX + CX -->|"verifies JWT against CLERK_JWT_ISSUER_DOMAIN"| CLK + CX -->|"convex-dev/resend component"| RS + RS -->|"staff and subscriber email"| B + GH --> GA + GH --> VG + GA -->|"bunx convex deploy"| CX + VG -->|"next build and deploy"| APP +`} +/> -``` -┌─────────────────────────────────────────────────────────────┐ -│ Client │ -│ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ Next.js App │───▶│ React Components │ │ -│ └─────────────────┘ └─────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────────┐ ┌─────────────────────┐ -│ Clerk │ │ Convex │ -│ Authentication │◀──▶│ Backend + DB │ -└─────────────────────┘ └─────────────────────┘ -``` +The frontend never proxies Convex calls. `ConvexProviderWithClerk` (in `src/components/ConvexClientProvider.tsx`) wires the Clerk session into the Convex client, so every query and mutation carries the user's JWT and `ctx.auth.getUserIdentity()` resolves on the backend. -## Key Components +## What runs where -### Frontend (Next.js) +| Concern | Where it lives | Source | +|---|---|---| +| Pages, routing, rendering | Next.js App Router on Vercel | `src/app/` | +| Data, business logic, authorization | Convex functions | `convex/` | +| Identity, sessions, OAuth | Clerk | `src/app/layout.tsx`, `src/proxy.ts` | +| Email delivery | Resend, called from Convex actions | `convex/resend.ts`, `convex/notifications.ts` | +| Scheduled cleanup | Convex cron | `convex/crons.ts` | +| Documentation site | Fumadocs, MDX in the repo | `content/docs/`, `src/app/docs/` | -- Server-side rendering for SEO -- React components with TypeScript -- Tailwind CSS for styling -- Real-time data subscriptions +## Frontend routes -### Authentication (Clerk) +| Route | File | Notes | +|---|---|---| +| `/` | `src/app/page.tsx` → `PageWrapper` | Public job list, search and filters | +| `/dashboard` | `src/app/dashboard/page.tsx` | Client component; pushes to `/auth/login?redirect_url=%2Fdashboard` when signed out. Also hosts the moderation panel | +| `/profile` | `src/app/profile/page.tsx` | Server component; calls Clerk `auth()` and redirects when there is no `userId` | +| `/auth/login/[[...rest]]` | Clerk `` | Catch-all so Clerk can own its sub-routes | +| `/auth/sso-callback` | `` | OAuth return target | +| `/unsubscribe` | `src/app/unsubscribe/page.tsx` | Token-based, works signed out | +| `/feedback`, `/privacy` | static pages | `/feedback` links out to `NEXT_PUBLIC_FEEDBACK_BOARD_URL` when set | +| `/docs/[[...slug]]` | Fumadocs page | This site | +| `/api/search` | `src/app/api/search/route.ts` | Fumadocs search index, `GET` only | -- OAuth providers GitHub -- Email/password authentication -- Session management -- User profile management +There is no `/admin` route. Moderation lives in `AdminSection`, rendered on `/dashboard` and returning `null` for anyone who is not a moderator or admin. See [Authentication](/docs/architecture/authentication) for what actually enforces that. -### Backend (Convex) +## Backend modules -- Real-time database with subscriptions -- Type-safe queries and mutations -- Automatic caching and optimization -- Cron jobs for scheduled tasks +| Module | Responsibility | +|---|---| +| `convex/schema.ts` | Six tables: `profiles`, `jobs`, `tags`, `trackedApplications`, `emailSubscriptions`, `jobNotificationDeliveries` | +| `convex/jobs.ts` | Public job listing and filtering, `postJob` with rate limits, the `deleteOldJobs` cron handler | +| `convex/admin.ts` | Moderation queries and mutations, all behind `requireModOrAdmin` | +| `convex/bookmarks.ts` | Tracked applications: bookmark toggle, status changes, notes | +| `convex/dashboard.ts` | One query that hydrates the whole dashboard in a single round trip | +| `convex/profiles.ts`, `convex/users.ts` | Profile read/update, first-sign-in profile creation, account deletion | +| `convex/emailSubscriptions.ts` | Opt-in, role filters, unsubscribe by token | +| `convex/notifications.ts`, `convex/resend.ts` | Staff and subscriber emails, Resend component setup | +| `convex/applicationIntegrity.ts`, `convex/jobSubmissionSecurity.ts`, `convex/jobHelpers.ts` | Pure helpers: status transition rules, submission validation, role-level inference and job snapshots | -## Data Flow +`applicationIntegrity.ts`, `jobSubmissionSecurity.ts` and `jobHelpers.ts` export plain functions rather than Convex functions, which is what makes them directly unit-testable. -1. User interacts with React component -2. Component calls Convex query/mutation -3. Convex validates request and checks auth -4. Database operation executes -5. Real-time update pushed to all clients +## Real-time and scheduled work + +Convex queries are subscriptions. When a moderator approves a job, every browser holding a `listApprovedJobs` subscription receives the new result without polling — see [Data Flow](/docs/architecture/data-flow). + +Two things run outside a user request: + +- `internal.jobs.deleteOldJobs`, scheduled daily at 00:00 UTC by `convex/crons.ts`, ages jobs out of the listing. +- `internal.notifications.sendJobApprovedUserEmails` and `internal.notifications.sendNewSubmissionStaffEmail`, scheduled with `ctx.scheduler.runAfter(0, ...)` from `updateJobStatus` and `postJob` respectively. Scheduling them instead of awaiting them keeps the mutation transactional and fast. + + +Convex dev and prod are separate deployments with separate environment variables. Setting `EMAIL_FROM`, `RESEND_API_KEY`, `STAFF_NOTIFICATION_EMAILS` or `NEXT_PUBLIC_SITE_URL` in dev does nothing for prod — set prod values with `bunx convex env set NAME value --prod` or in the prod deployment's dashboard. Notification actions log a warning and return `success: false` when a variable they need is missing, so a misconfigured prod deployment fails quietly. + + +## Deployment + +`.github/workflows/deploy.yml` runs on pushes to `Master`: lint, type check, tests and a build, then `bunx convex deploy`. The workflow's Vercel job is gated behind the repository variable `VERCEL_CONFIGURED`, so in practice the frontend ships through the Vercel Git integration. Full details in [Deployment](/docs/technical/deployment). + + +`NEXT_PUBLIC_*` variables are inlined into the JavaScript bundle at build time. Changing `NEXT_PUBLIC_CONVEX_URL` or `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` requires a fresh build, not just a redeploy of the existing one. + diff --git a/content/docs/architecture/tech-stack.mdx b/content/docs/architecture/tech-stack.mdx index aba9c88..49d1bea 100644 --- a/content/docs/architecture/tech-stack.mdx +++ b/content/docs/architecture/tech-stack.mdx @@ -1,33 +1,72 @@ --- title: Tech Stack -description: Technologies powering FirstDevJob +description: Every dependency and service FirstDevJob runs on, with the version declared in package.json --- -## Why These Choices? +This page lists what the project actually depends on and why. Versions are the specifiers declared in `package.json` at the root of the repo; `bun.lock` holds the exact resolved versions. If a version here disagrees with `package.json`, `package.json` wins. -### Next.js + React +## Runtime dependencies -- Industry standard for React applications -- Excellent developer experience -- Built-in optimizations (SSR, ISR, code splitting) +| Package | Version | Role | +|---|---|---| +| `next` | `16.3.5` | App Router framework, pinned exactly rather than to a range | +| `react`, `react-dom` | `^19.2.4` | UI runtime | +| `convex` | `^1.31.7` | Database, queries, mutations, actions, crons, and the React client | +| `@clerk/nextjs` | `^6.37.1` | Auth provider, sign-in UI, `clerkMiddleware`, server `auth()` | +| `@convex-dev/resend` | `^0.2.3` | Convex component that queues and sends email through Resend | +| `fumadocs-core` | `^16.5.0` | Docs content loader and search backend | +| `fumadocs-ui` | `^16.5.0` | Docs layout, sidebar, search dialog, MDX components | +| `fumadocs-mdx` | `^14.2.6` | Compiles `content/docs/**/*.mdx` into the `.source` bundle | +| `mermaid` | `^12.0.0` | Renders the diagrams in these docs, loaded lazily client-side | +| `lucide-react` | `^0.513.0` | Icon set | +| `next-themes` | `^0.4.6` | Theme state for the docs layout and diagram colours | -### Convex +## Build and development tooling -- Real-time subscriptions out of the box -- Type-safe end-to-end -- No need to manage infrastructure -- Built-in cron jobs and scheduled functions +| Package | Version | Role | +|---|---|---| +| `typescript` | `^5.9.3` | Strict typing end to end, including generated Convex types | +| `tailwindcss` + `@tailwindcss/postcss` | `^4.1.18` | Styling, configured through PostCSS rather than a `tailwind.config` file | +| `eslint` + `eslint-config-next` | `^9.39.2` / `15.5.11` | Linting | +| `prettier` | `^3.8.1` | Formatting | +| `jest`, `jest-environment-jsdom` | `^30` | Test runner | +| `ts-jest` | `^29.4.6` | TypeScript transform for Jest | +| `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event` | see `package.json` | Component tests | +| `@next/bundle-analyzer` | `^15.5.11` | Opt-in bundle report, enabled by `ANALYZE=true` | -### Clerk + +The package manager is Bun, pinned to `1.4.2` by the `packageManager` field and by `BUN_VERSION` in `.github/workflows/ci.yml`. Use `bun install`, `bun run