diff --git a/db/migrations/0011_issue_feedback.sql b/db/migrations/0011_issue_feedback.sql new file mode 100644 index 0000000..2f6890c --- /dev/null +++ b/db/migrations/0011_issue_feedback.sql @@ -0,0 +1,30 @@ +CREATE TABLE hidden_repository ( + id text PRIMARY KEY NOT NULL, + user_id text NOT NULL, + repository_full_name text NOT NULL, + created_at integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (user_id) REFERENCES user(id) ON UPDATE no action ON DELETE cascade +); + +CREATE TABLE issue_feedback ( + id text PRIMARY KEY NOT NULL, + user_id text NOT NULL, + repository_full_name text NOT NULL, + issue_number integer NOT NULL, + issue_url text NOT NULL, + reason text NOT NULL, + created_at integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (user_id) REFERENCES user(id) ON UPDATE no action ON DELETE cascade +); + +CREATE UNIQUE INDEX hidden_repository_user_repo_uidx + ON hidden_repository (user_id, repository_full_name); + +CREATE INDEX hidden_repository_user_id_idx + ON hidden_repository (user_id); + +CREATE UNIQUE INDEX issue_feedback_user_repository_issue_uidx + ON issue_feedback (user_id, repository_full_name, issue_number); + +CREATE INDEX issue_feedback_user_id_idx + ON issue_feedback (user_id); diff --git a/package-lock.json b/package-lock.json index 87bc9b0..99fa2b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "version": "6.0.7", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@csstools/css-calc": "^3.3.0", @@ -80,7 +80,7 @@ "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -90,7 +90,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "bidi-js": "^1.0.3", @@ -106,7 +106,7 @@ "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -531,7 +531,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -674,7 +674,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "css-tree": "^3.0.0" @@ -687,7 +687,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -707,7 +707,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -731,7 +731,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -759,7 +759,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -782,7 +782,7 @@ "version": "1.1.8", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -807,7 +807,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -1300,7 +1300,7 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -1542,9 +1542,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1561,9 +1558,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1580,9 +1574,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1599,9 +1590,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1618,9 +1606,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1637,9 +1622,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1656,9 +1638,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1675,9 +1654,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1694,9 +1670,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1719,9 +1692,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1744,9 +1714,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1769,9 +1736,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1794,9 +1758,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1819,9 +1780,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1844,9 +1802,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1869,9 +1824,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2326,9 +2278,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2345,9 +2294,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2364,9 +2310,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2383,9 +2326,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4018,6 +3958,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4034,6 +3975,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4050,6 +3992,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4066,6 +4009,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4082,6 +4026,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4098,6 +4043,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4114,9 +4060,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4133,9 +4077,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4152,9 +4094,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4171,9 +4111,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4190,9 +4128,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4209,9 +4145,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4228,6 +4162,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4244,6 +4179,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4260,6 +4196,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4465,9 +4402,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4485,9 +4419,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4505,9 +4436,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4525,9 +4453,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5066,9 +4991,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5083,9 +5005,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5100,9 +5019,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5117,9 +5033,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5134,9 +5047,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5151,9 +5061,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5168,9 +5075,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5185,9 +5089,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5202,9 +5103,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5219,9 +5117,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5318,7 +5213,7 @@ "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", @@ -5802,7 +5697,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", @@ -5814,7 +5709,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/async-function": { @@ -6031,7 +5926,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -6559,7 +6454,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -6599,7 +6494,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", @@ -6613,7 +6508,7 @@ "version": "16.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -6714,7 +6609,7 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/dedent": { @@ -7204,7 +7099,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -8588,6 +8483,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8861,7 +8757,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8966,7 +8862,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.6.0" @@ -8979,7 +8875,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/html-to-text": { @@ -9522,7 +9418,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/is-promise": { @@ -9744,7 +9640,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=8" @@ -9754,7 +9650,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", @@ -9769,7 +9665,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", @@ -9801,7 +9697,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -9854,7 +9750,7 @@ "version": "30.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^6.0.5", @@ -9895,7 +9791,7 @@ "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -9905,7 +9801,7 @@ "version": "8.10.0", "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=22.19.0" @@ -10251,9 +10147,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10275,9 +10168,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10299,9 +10189,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10323,9 +10210,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10505,7 +10389,7 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -10517,7 +10401,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" @@ -10533,7 +10417,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10555,7 +10439,7 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "devOptional": true, + "dev": true, "license": "CC0-1.0" }, "node_modules/media-typer": { @@ -11270,7 +11154,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "entities": "^8.0.0" @@ -11626,7 +11510,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12174,7 +12058,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -12965,7 +12849,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -12991,7 +12875,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/systeminformation": { @@ -13136,7 +13020,7 @@ "version": "7.4.10", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "tldts-core": "^7.4.10" @@ -13149,7 +13033,7 @@ "version": "7.4.10", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/to-regex-range": { @@ -13177,7 +13061,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -13190,7 +13074,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -13723,6 +13607,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13743,6 +13628,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13763,6 +13649,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13783,6 +13670,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13803,6 +13691,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13823,9 +13712,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13846,9 +13733,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13869,9 +13754,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13892,9 +13775,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13915,6 +13796,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13935,6 +13817,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14068,7 +13951,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -14081,7 +13964,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -14091,7 +13974,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -14101,7 +13984,7 @@ "version": "17.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.15.1", @@ -14290,7 +14173,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -14300,7 +14183,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/yallist": { diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts new file mode 100644 index 0000000..a6094e3 --- /dev/null +++ b/src/app/api/feedback/route.ts @@ -0,0 +1,48 @@ +import { auth } from '@/lib/auth'; +import { getDatabase } from '@/lib/db'; +import { hiddenRepository, issueFeedback } from '@/lib/auth-schema'; + +export async function POST(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session) { + return Response.json({ error: 'Unauthorized.' }, { status: 401 }); + } + + const { repositoryFullName, issueNumber, issueUrl, reason } = await request.json(); + + if (!repositoryFullName || !issueNumber || !issueUrl || !reason) { + return Response.json({ error: 'Missing required fields.' }, { status: 400 }); + } + + const database = getDatabase(); + + try { + if (reason === 'Hide this repository') { + await database + .insert(hiddenRepository) + .values({ + id: crypto.randomUUID(), + userId: session.user.id, + repositoryFullName, + }) + .onConflictDoNothing(); + } else { + await database + .insert(issueFeedback) + .values({ + id: crypto.randomUUID(), + userId: session.user.id, + repositoryFullName, + issueNumber, + issueUrl, + reason, + }) + .onConflictDoNothing(); + } + + return Response.json({ success: true }); + } catch { + return Response.json({ error: 'Failed to save feedback.' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/hidden-repositories/route.ts b/src/app/api/hidden-repositories/route.ts new file mode 100644 index 0000000..ba47206 --- /dev/null +++ b/src/app/api/hidden-repositories/route.ts @@ -0,0 +1,59 @@ +import { eq, and } from 'drizzle-orm'; +import { auth } from '@/lib/auth'; +import { getDatabase } from '@/lib/db'; +import { hiddenRepository } from '@/lib/auth-schema'; + +export async function GET(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session) { + return Response.json({ error: 'Unauthorized.' }, { status: 401 }); + } + + const database = getDatabase(); + try { + const repos = await database + .select({ + id: hiddenRepository.id, + repositoryFullName: hiddenRepository.repositoryFullName, + createdAt: hiddenRepository.createdAt, + }) + .from(hiddenRepository) + .where(eq(hiddenRepository.userId, session.user.id)); + + return Response.json({ repositories: repos }); + } catch { + return Response.json({ error: 'Failed to fetch hidden repositories.' }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session) { + return Response.json({ error: 'Unauthorized.' }, { status: 401 }); + } + + const url = new URL(request.url); + const repoName = url.searchParams.get('repositoryFullName'); + + if (!repoName) { + return Response.json({ error: 'Missing repositoryFullName parameter.' }, { status: 400 }); + } + + const database = getDatabase(); + try { + await database + .delete(hiddenRepository) + .where( + and( + eq(hiddenRepository.userId, session.user.id), + eq(hiddenRepository.repositoryFullName, repoName) + ) + ); + + return Response.json({ success: true }); + } catch { + return Response.json({ error: 'Failed to unhide repository.' }, { status: 500 }); + } +} diff --git a/src/app/api/recommendations/route.ts b/src/app/api/recommendations/route.ts index 3db2188..279d67b 100644 --- a/src/app/api/recommendations/route.ts +++ b/src/app/api/recommendations/route.ts @@ -1,6 +1,6 @@ import { desc, eq } from "drizzle-orm"; import { auth } from "@/lib/auth"; -import { opportunity, savedSearch } from "@/lib/auth-schema"; +import { opportunity, savedSearch, issueFeedback, hiddenRepository } from "@/lib/auth-schema"; import { getDatabase } from "@/lib/db"; import { buildPersonalizedRecommendations } from "@/features/issues/server/personalized-recommendations"; import { isSearchRateLimited } from "@/features/issues/server/search-rate-limit"; @@ -23,7 +23,7 @@ export async function GET(request: Request) { const selectedSearchId = searchIds[0]; const database = getDatabase(); - const [searches, opportunities] = await Promise.all([ + const [searches, opportunities, feedbacks, hiddenRepos] = await Promise.all([ database .select() .from(savedSearch) @@ -36,6 +36,18 @@ export async function GET(request: Request) { }) .from(opportunity) .where(eq(opportunity.userId, session.user.id)), + database + .select({ + issueUrl: issueFeedback.issueUrl, + }) + .from(issueFeedback) + .where(eq(issueFeedback.userId, session.user.id)), + database + .select({ + repositoryFullName: hiddenRepository.repositoryFullName, + }) + .from(hiddenRepository) + .where(eq(hiddenRepository.userId, session.user.id)), ]); try { @@ -76,6 +88,10 @@ export async function GET(request: Request) { ] : [], opportunities, + { + dismissedIssueUrls: new Set(feedbacks.map((f) => f.issueUrl)), + hiddenRepositories: new Set(hiddenRepos.map((r) => r.repositoryFullName.toLowerCase())), + } ); return Response.json(result); diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..c263ad5 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,269 @@ +"use client" + +import * as React from "react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { CheckIcon, ChevronRightIcon } from "lucide-react" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/features/issues/components/hidden-repositories.tsx b/src/features/issues/components/hidden-repositories.tsx new file mode 100644 index 0000000..8712b5e --- /dev/null +++ b/src/features/issues/components/hidden-repositories.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; + +type HiddenRepo = { id: string; repositoryFullName: string; createdAt: string }; + +export function HiddenRepositories() { + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + fetch('/api/hidden-repositories') + .then(async (response) => { + if (response.ok) { + const data = await response.json(); + if (!cancelled) setRepos(data.repositories); + } + }) + .catch((error) => console.error(error)) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + async function handleUnhide(repoName: string) { + try { + const response = await fetch(`/api/hidden-repositories?repositoryFullName=${encodeURIComponent(repoName)}`, { + method: 'DELETE', + }); + if (response.ok) { + setRepos(repos.filter(r => r.repositoryFullName !== repoName)); + } + } catch (e) { + console.error(e); + } + } + + if (loading) return
Loading...
; + + return ( + + + Hidden Repositories + + + {repos.length === 0 ? ( +

You have not hidden any repositories.

+ ) : ( +
    + {repos.map(repo => ( +
  • + {repo.repositoryFullName} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/features/issues/components/issue-card.tsx b/src/features/issues/components/issue-card.tsx index fa64e49..13812ad 100644 --- a/src/features/issues/components/issue-card.tsx +++ b/src/features/issues/components/issue-card.tsx @@ -9,6 +9,12 @@ import { } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Card, CardContent, @@ -255,12 +261,14 @@ export function IssueCard({ matchSignals = [], onOpen, onSaveChange, + onDismiss, }: Readonly<{ issue: Issue; isSaved?: boolean; matchSignals?: string[]; onOpen?: (issue: Issue) => void; onSaveChange?: (issue: Issue, saved: boolean) => void; + onDismiss?: (issue: Issue, reason: string) => void; }>) { return ( @@ -345,6 +353,33 @@ export function IssueCard({ {isSaved ? "Saved" : "Save"} ) : null} + {onDismiss ? ( + + + + + + onDismiss(issue, "Not interested")}> + Not interested + + onDismiss(issue, "Wrong technology")}> + Wrong technology + + onDismiss(issue, "Too difficult")}> + Too difficult + + onDismiss(issue, "Already claimed")}> + Already claimed + + + onDismiss(issue, "Hide this repository")} className="text-red-600 focus:text-red-600"> + Hide this repository + + + + ) : null} ) : null} + {authenticated ? ( + + ) : null} {activePanel} diff --git a/src/features/issues/server/personalized-recommendations.ts b/src/features/issues/server/personalized-recommendations.ts index 8dd5fb7..65203ea 100644 --- a/src/features/issues/server/personalized-recommendations.ts +++ b/src/features/issues/server/personalized-recommendations.ts @@ -42,6 +42,7 @@ function addFamiliarRepositorySignal(recommendation: RecommendedIssue) { export async function buildPersonalizedRecommendations( savedSearches: SavedSearch[], opportunities: Array<{ issueUrl: string; repositoryFullName: string }>, + feedback: { dismissedIssueUrls: Set; hiddenRepositories: Set } = { dismissedIssueUrls: new Set(), hiddenRepositories: new Set() }, ): Promise<{ recommendations: RecommendedIssue[]; preferenceCount: number }> { const distinctPreferences = new Map(); @@ -86,6 +87,8 @@ export async function buildPersonalizedRecommendations( for (const issue of result.issues) { if (excludedUrls.has(issue.url)) continue; + if (feedback.dismissedIssueUrls.has(issue.url)) continue; + if (feedback.hiddenRepositories.has(issue.repo.toLowerCase())) continue; const current = recommendations.get(issue.id) ?? { issue, diff --git a/src/lib/auth-schema.ts b/src/lib/auth-schema.ts index 764cdc0..995ee04 100644 --- a/src/lib/auth-schema.ts +++ b/src/lib/auth-schema.ts @@ -250,6 +250,8 @@ export const userRelations = relations(user, ({ many }) => ({ accounts: many(account), savedSearches: many(savedSearch), opportunities: many(opportunity), + issueFeedback: many(issueFeedback), + hiddenRepositories: many(hiddenRepository), })); export const sessionRelations = relations(session, ({ one }) => ({ @@ -279,3 +281,63 @@ export const opportunityRelations = relations(opportunity, ({ one }) => ({ references: [user.id], }), })); + +export const issueFeedback = sqliteTable( + "issue_feedback", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + repositoryFullName: text("repository_full_name").notNull(), + issueNumber: integer("issue_number").notNull(), + issueUrl: text("issue_url").notNull(), + reason: text("reason").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (table) => [ + uniqueIndex("issue_feedback_user_repository_issue_uidx").on( + table.userId, + table.repositoryFullName, + table.issueNumber, + ), + index("issue_feedback_user_id_idx").on(table.userId), + ], +); + +export const hiddenRepository = sqliteTable( + "hidden_repository", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + repositoryFullName: text("repository_full_name").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (table) => [ + uniqueIndex("hidden_repository_user_repo_uidx").on( + table.userId, + table.repositoryFullName, + ), + index("hidden_repository_user_id_idx").on(table.userId), + ], +); + +export const issueFeedbackRelations = relations(issueFeedback, ({ one }) => ({ + user: one(user, { + fields: [issueFeedback.userId], + references: [user.id], + }), +})); + +export const hiddenRepositoryRelations = relations(hiddenRepository, ({ one }) => ({ + user: one(user, { + fields: [hiddenRepository.userId], + references: [user.id], + }), +})); diff --git a/tests/app/api/feedback/route.test.ts b/tests/app/api/feedback/route.test.ts new file mode 100644 index 0000000..ca262b7 --- /dev/null +++ b/tests/app/api/feedback/route.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getSession, insert, values, onConflictDoNothing } = vi.hoisted(() => ({ + getSession: vi.fn(), + insert: vi.fn(), + values: vi.fn(), + onConflictDoNothing: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/auth", () => ({ auth: { api: { getSession } } })); +vi.mock("@/lib/db", () => ({ + getDatabase: () => ({ insert }), +})); +vi.mock("@/lib/utils", () => ({ crypto: { randomUUID: () => "uuid" } })); +vi.mock("@/lib/auth-schema", () => ({ issueFeedback: {}, hiddenRepository: {} })); + +import { POST } from "@/app/api/feedback/route"; + +describe("feedback API", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSession.mockResolvedValue({ user: { id: "user-1" } }); + insert.mockReturnValue({ values }); + values.mockReturnValue({ onConflictDoNothing }); + onConflictDoNothing.mockResolvedValue(undefined); + }); + + it("requires authentication", async () => { + getSession.mockResolvedValue(null); + expect((await POST(new Request("http://localhost/api/feedback", { method: "POST" }))).status).toBe(401); + }); + + it("requires required fields", async () => { + const response = await POST(new Request("http://localhost/api/feedback", { + method: "POST", + body: JSON.stringify({}), + })); + expect(response.status).toBe(400); + }); + + it("inserts hidden repository", async () => { + const response = await POST(new Request("http://localhost/api/feedback", { + method: "POST", + body: JSON.stringify({ + repositoryFullName: "acme/repo", + issueNumber: 12, + issueUrl: "http://github.com/acme/repo/issues/12", + reason: "Hide this repository" + }), + })); + expect(response.status).toBe(200); + expect(insert).toHaveBeenCalled(); + }); + + it("inserts feedback", async () => { + const response = await POST(new Request("http://localhost/api/feedback", { + method: "POST", + body: JSON.stringify({ + repositoryFullName: "acme/repo", + issueNumber: 12, + issueUrl: "http://github.com/acme/repo/issues/12", + reason: "Not interested" + }), + })); + expect(response.status).toBe(200); + expect(insert).toHaveBeenCalled(); + }); +}); diff --git a/tests/app/api/hidden-repositories/route.test.ts b/tests/app/api/hidden-repositories/route.test.ts new file mode 100644 index 0000000..81b1c90 --- /dev/null +++ b/tests/app/api/hidden-repositories/route.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getSession, select, from, where, remove, deleteWhere, and, eq } = vi.hoisted(() => ({ + getSession: vi.fn(), + select: vi.fn(), + from: vi.fn(), + where: vi.fn(), + remove: vi.fn(), + deleteWhere: vi.fn(), + and: vi.fn(), + eq: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/auth", () => ({ auth: { api: { getSession } } })); +vi.mock("drizzle-orm", () => ({ and, eq })); +vi.mock("@/lib/db", () => ({ + getDatabase: () => ({ select, delete: remove }), +})); +vi.mock("@/lib/auth-schema", () => ({ hiddenRepository: {} })); + +import { GET, DELETE } from "@/app/api/hidden-repositories/route"; + +describe("hidden-repositories API", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSession.mockResolvedValue({ user: { id: "user-1" } }); + select.mockReturnValue({ from }); + from.mockReturnValue({ where }); + where.mockResolvedValue([]); + remove.mockReturnValue({ where: deleteWhere }); + deleteWhere.mockResolvedValue(undefined); + }); + + it("requires authentication for GET", async () => { + getSession.mockResolvedValue(null); + expect((await GET(new Request("http://localhost/api/hidden-repositories"))).status).toBe(401); + }); + + it("requires authentication for DELETE", async () => { + getSession.mockResolvedValue(null); + expect((await DELETE(new Request("http://localhost/api/hidden-repositories"))).status).toBe(401); + }); + + it("returns repositories on GET", async () => { + where.mockResolvedValue([{ id: "1", repositoryFullName: "acme/repo" }]); + const response = await GET(new Request("http://localhost/api/hidden-repositories")); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ repositories: [{ id: "1", repositoryFullName: "acme/repo" }] }); + }); + + it("deletes repository on DELETE", async () => { + const response = await DELETE(new Request("http://localhost/api/hidden-repositories?repositoryFullName=acme/repo")); + expect(response.status).toBe(200); + expect(remove).toHaveBeenCalled(); + }); + + it("requires repo param for DELETE", async () => { + const response = await DELETE(new Request("http://localhost/api/hidden-repositories")); + expect(response.status).toBe(400); + }); +}); diff --git a/tests/app/api/recommendations/route.test.ts b/tests/app/api/recommendations/route.test.ts index f3c6cf0..3fd9f25 100644 --- a/tests/app/api/recommendations/route.test.ts +++ b/tests/app/api/recommendations/route.test.ts @@ -9,6 +9,8 @@ const { isSearchRateLimited, opportunity, savedSearch, + issueFeedback, + hiddenRepository, } = vi.hoisted(() => ({ buildPersonalizedRecommendations: vi.fn(), desc: vi.fn(), @@ -25,12 +27,20 @@ const { createdAt: "savedSearch.createdAt", userId: "savedSearch.userId", }, + issueFeedback: { + issueUrl: "issueFeedback.issueUrl", + userId: "issueFeedback.userId", + }, + hiddenRepository: { + repositoryFullName: "hiddenRepository.repositoryFullName", + userId: "hiddenRepository.userId", + } })); vi.mock("server-only", () => ({})); vi.mock("drizzle-orm", () => ({ desc, eq })); vi.mock("@/lib/auth", () => ({ auth: { api: { getSession } } })); -vi.mock("@/lib/auth-schema", () => ({ opportunity, savedSearch })); +vi.mock("@/lib/auth-schema", () => ({ opportunity, savedSearch, issueFeedback, hiddenRepository })); vi.mock("@/lib/db", () => ({ getDatabase })); vi.mock("@/features/issues/server/personalized-recommendations", () => ({ buildPersonalizedRecommendations, @@ -140,6 +150,7 @@ describe("recommendations API", () => { expect(buildPersonalizedRecommendations).toHaveBeenCalledWith( [expect.objectContaining({ id: "search-2", createdAt: "2026-08-29T00:00:00.000Z" })], opportunities, + expect.any(Object), ); }); @@ -151,6 +162,7 @@ describe("recommendations API", () => { expect(buildPersonalizedRecommendations).toHaveBeenCalledWith( [expect.objectContaining({ id: "search-1" })], opportunities, + expect.any(Object), ); }); @@ -168,7 +180,7 @@ describe("recommendations API", () => { const response = await GET(new Request("http://localhost/api/recommendations")); expect(response.status).toBe(200); - expect(buildPersonalizedRecommendations).toHaveBeenCalledWith([], opportunities); + expect(buildPersonalizedRecommendations).toHaveBeenCalledWith([], opportunities, expect.any(Object)); }); it("converts GitHub failures to a gateway error", async () => { diff --git a/tests/features/issues/components/hidden-repositories.test.tsx b/tests/features/issues/components/hidden-repositories.test.tsx new file mode 100644 index 0000000..be30a36 --- /dev/null +++ b/tests/features/issues/components/hidden-repositories.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HiddenRepositories } from "@/features/issues/components/hidden-repositories"; + +const fetchMock = vi.fn(); +globalThis.fetch = fetchMock; + +describe("HiddenRepositories", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders loading state initially", () => { + fetchMock.mockReturnValue(new Promise(() => {})); // Never resolves + render(); + expect(screen.getByText("Loading...")).toBeDefined(); + }); + + it("renders empty state", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ repositories: [] }), + }); + render(); + await waitFor(() => { + expect(screen.getByText("You have not hidden any repositories.")).toBeDefined(); + }); + }); + + it("renders the empty state when loading repositories fails", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + fetchMock.mockRejectedValue(new Error("network failure")); + + render(); + + expect( + await screen.findByText("You have not hidden any repositories."), + ).toBeDefined(); + expect(consoleError).toHaveBeenCalledWith(expect.any(Error)); + + consoleError.mockRestore(); + }); + + it("renders the empty state for a non-OK repository response", async () => { + fetchMock.mockResolvedValue({ ok: false }); + + render(); + + expect( + await screen.findByText("You have not hidden any repositories."), + ).toBeDefined(); + }); + + it("does not update state after unmounting", async () => { + let resolveFetch: ((value: unknown) => void) | undefined; + fetchMock.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const view = render(); + view.unmount(); + resolveFetch?.({ + ok: true, + json: async () => ({ repositories: [] }), + }); + + await Promise.resolve(); + await Promise.resolve(); + }); + + it("renders repositories and handles unhide", async () => { + fetchMock.mockImplementation((url, init) => { + if (url === "/api/hidden-repositories" && !init) { + return Promise.resolve({ + ok: true, + json: async () => ({ + repositories: [{ id: "1", repositoryFullName: "acme/repo", createdAt: "" }], + }), + }); + } + if (init?.method === "DELETE") { + return Promise.resolve({ ok: true }); + } + return Promise.resolve({ ok: false }); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("acme/repo")).toBeDefined(); + }); + + const user = userEvent.setup(); + const unhideBtn = screen.getByRole("button", { name: "Unhide" }); + await user.click(unhideBtn); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/hidden-repositories?repositoryFullName=acme%2Frepo"), + expect.objectContaining({ method: "DELETE" }) + ); + + await waitFor(() => { + expect(screen.queryByText("acme/repo")).toBeNull(); + }); + }); + + it("keeps a repository visible when unhide fails", async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + repositories: [{ id: "1", repositoryFullName: "acme/repo", createdAt: "" }], + }), + }) + .mockResolvedValueOnce({ ok: false }); + + render(); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "Unhide" })); + + expect(screen.getByText("acme/repo")).toBeDefined(); + }); + + it("keeps a repository visible when the unhide request rejects", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + repositories: [{ id: "1", repositoryFullName: "acme/repo", createdAt: "" }], + }), + }) + .mockRejectedValueOnce(new Error("network failure")); + + render(); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "Unhide" })); + + expect(screen.getByText("acme/repo")).toBeDefined(); + expect(consoleError).toHaveBeenCalledWith(expect.any(Error)); + + consoleError.mockRestore(); + }); +}); diff --git a/tests/features/issues/components/issue-card.test.tsx b/tests/features/issues/components/issue-card.test.tsx index c50ed2c..9efafb5 100644 --- a/tests/features/issues/components/issue-card.test.tsx +++ b/tests/features/issues/components/issue-card.test.tsx @@ -7,6 +7,7 @@ import { render as testingLibraryRender, screen, } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { IssueCard } from "@/features/issues/components/issue-card"; @@ -123,6 +124,31 @@ describe("issue presentation", () => { expect(screen.getByText("Health unknown")).toBeTruthy(); }); + it("renders classification and responsiveness signals", () => { + render( + , + ); + + expect(screen.getByText("responsive maintainers")).toBeTruthy(); + expect(screen.getByText("Beginner friendly")).toBeTruthy(); + expect(screen.getByText("Small scope")).toBeTruthy(); + }); + it("does not imply unavailable enrichment was measured", () => { render( { expect(onSaveChange).toHaveBeenCalledWith(selectedIssue, true); expect(onOpen).toHaveBeenCalledWith(selectedIssue); }); + + it("reports recommendation dismissal feedback", async () => { + const onDismiss = vi.fn(); + const selectedIssue = issue(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Dismiss" })); + await user.click(screen.getByRole("menuitem", { name: "Not interested" })); + + expect(onDismiss).toHaveBeenCalledWith(selectedIssue, "Not interested"); + }); }); diff --git a/tests/features/issues/server/personalized-recommendations.test.ts b/tests/features/issues/server/personalized-recommendations.test.ts index 0a9dc6a..9a1524a 100644 --- a/tests/features/issues/server/personalized-recommendations.test.ts +++ b/tests/features/issues/server/personalized-recommendations.test.ts @@ -144,6 +144,32 @@ describe("personalized recommendations", () => { expect(result.recommendations[0].matchSignals).toContain("Familiar repository"); }); + it("excludes dismissed issues and hidden repositories", async () => { + const dismissedIssue = issue(1); + mockedSearch.mockResolvedValue({ + query: "query", + totalCount: 3, + candidateCount: 3, + rateLimitRemaining: "100", + tokenConfigured: true, + issues: [dismissedIssue, issue(2, "Acme/Hidden"), issue(3, "acme/visible")], + page: 1, + }); + + const result = await buildPersonalizedRecommendations( + [savedSearch("search", "TypeScript", "help-wanted", "2026-08-29T00:00:00.000Z")], + [], + { + dismissedIssueUrls: new Set([dismissedIssue.url]), + hiddenRepositories: new Set(["acme/hidden"]), + }, + ); + + expect(result.recommendations.map((item) => item.issue.id)).toEqual([ + "issue-3", + ]); + }); + it("uses recency to break equal recommendation scores", async () => { const olderIssue = issue(1); olderIssue.updatedAt = "2026-08-28T00:00:00.000Z";