diff --git a/bun.lock b/bun.lock
index 6af7904..9a8e76a 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",
@@ -42,11 +43,16 @@
},
},
},
+ "overrides": {
+ "lodash-es": "^4.18.1",
+ },
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@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 +127,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 +267,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 +381,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 +575,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 +677,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 +745,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 +853,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 +893,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 +905,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 +987,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 +1007,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 +1023,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 +1033,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 +1061,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 +1215,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 +1269,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 +1283,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 +1453,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 +1497,8 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+ "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
+
"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 +1525,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 +1565,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 +1717,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 +1727,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 +1749,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 +1843,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 +1957,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 +2003,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 +2063,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 +2127,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 +2229,12 @@
"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=="],
+
"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 +2271,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 +2297,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 +2355,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 +2423,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..06b3e3f 100644
--- a/content/docs/architecture/overview.mdx
+++ b/content/docs/architecture/overview.mdx
@@ -1,56 +1,103 @@
---
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
+|"deploy.yml"| GA["GitHub Actions"]
+ GH -->|"Git integration"| VG["Vercel build"]
+ end
+ subgraph Runtime["Runtime"]
+ direction TB
+ B["Browser"]
+ APP["Next.js app on Vercel"]
+ CLK["Clerk"]
+ CX["Convex deployment"]
+ RS["Resend"]
+ end
+ GA -->|"bunx convex deploy"| CX
+ VG -->|"next build and deploy"| APP
+ B -->|"HTTP page loads"| APP
+ B -->|"sign in"| CLK
+ CLK -->|"session JWT"| B
+ B -->|"queries and mutations over WebSocket"| CX
+ CX -->|"verifies JWT via CLERK_JWT_ISSUER_DOMAIN"| CLK
+ CX -->|"@convex-dev/resend component"| RS
+ RS -->|"staff and subscriber email"| B
+`}
+/>
-```
-┌─────────────────────────────────────────────────────────────┐
-│ 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