From fb089845617fe32f9138719f3d8ca0941b7da990 Mon Sep 17 00:00:00 2001 From: Matthew Moen Date: Mon, 13 Oct 2025 21:28:40 -0600 Subject: [PATCH] Add ScreenerNotification --- bun.lock | 175 ++++++++++++++++++++ index.ts | 447 ++++++++++++++++++++++----------------------------- lib/redis.ts | 22 +++ package.json | 3 + 4 files changed, 396 insertions(+), 251 deletions(-) create mode 100644 lib/redis.ts diff --git a/bun.lock b/bun.lock index 2171836..aa38d0d 100644 --- a/bun.lock +++ b/bun.lock @@ -8,12 +8,15 @@ "@prisma/client": "^6.12.0", "ai": "^5.0.24", "dotenv": "^17.2.1", + "express": "^5.1.0", + "ioredis": "^5.8.0", "openai": "^5.10.1", "redis": "^5.6.0", "zod": "^4.0.11", }, "devDependencies": { "@types/bun": "latest", + "@types/express": "^5.0.3", "prisma": "^6.12.0", }, "peerDependencies": { @@ -30,6 +33,8 @@ "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.6", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-s1+9okDSqbxKvwf1mqyyqtOY27/RV9O+XTzaRKEamVKbmVBM7BiCSfui7vH7A/1EETECtTJkS2MUL5D3Pw5GFw=="], + "@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@prisma/client": ["@prisma/client@6.12.0", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-wn98bJ3Cj6edlF4jjpgXwbnQIo/fQLqqQHPk2POrZPxTlhY3+n90SSIF3LMRVa8VzRFC/Gec3YKJRxRu+AIGVA=="], @@ -58,38 +63,208 @@ "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/express": ["@types/express@5.0.3", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "*" } }, "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.0.7", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], + "@types/node": ["@types/node@24.0.15", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-oaeTSbCef7U/z7rDeJA138xpG3NuKc64/rZ2qmUFkFJmnMsAPaluIifqyWd8hSSMxyP9oie3dLAqYPblag9KgA=="], + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@types/send": ["@types/send@0.17.5", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w=="], + + "@types/serve-static": ["@types/serve-static@1.15.8", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "*" } }, "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "ai": ["ai@5.0.24", "", { "dependencies": { "@ai-sdk/gateway": "1.0.13", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.6", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-C7wGp5U3UkqR/ott5uoRZamlaks8ur3PgDE/VtdIDnbEXkwGrYlahDjeWwVf6XLT+DxDLPnkFo1HjgEOdhhBfw=="], + "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], + "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dotenv": ["dotenv@17.2.1", "", {}, "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ=="], + "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=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventsource-parser": ["eventsource-parser@3.0.5", "", {}, "sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ=="], + "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], + + "finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ioredis": ["ioredis@5.8.0", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-AUXbKn9gvo9hHKvk6LbZJQSKn/qIfkWXrnsyL9Yrf+oeXmla9Nmf6XEumOddyhM8neynpK5oAV6r9r99KBuwzA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + + "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "openai": ["openai@5.10.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-fq6xVfv1/gpLbsj8fArEt3b6B9jBxdhAK+VJ+bDvbUvNd+KTLlA3bnDeYZaBsGH9LUhJ1M1yXfp9sEyBLMx6eA=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + "prisma": ["prisma@6.12.0", "", { "dependencies": { "@prisma/config": "6.12.0", "@prisma/engines": "6.12.0" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-pmV7NEqQej9WjizN6RSNIwf7Y+jeh9mY1JEX2WjGxJi4YZWexClhde1yz/FuvAM+cTwzchcMytu2m4I6wPkIzg=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="], + "redis": ["redis@5.6.0", "", { "dependencies": { "@redis/bloom": "5.6.0", "@redis/client": "5.6.0", "@redis/json": "5.6.0", "@redis/search": "5.6.0", "@redis/time-series": "5.6.0" } }, "sha512-0x3pM3SlYA5azdNwO8qgfMBzoOqSqr9M+sd1hojbcn0ZDM5zsmKeMM+zpTp6LIY+mbQomIc/RTTQKuBzr8QKzQ=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], + + "serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "zod": ["zod@4.0.11", "", {}, "sha512-LVrgstTaQJek72n6ZGxhAhH/Q24PhGx4lIAcgBmjtvjRq0qYjiH9U0o3hfuC2vfExsnpoHElc4XOJjMKQjUQxg=="], + + "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + + "raw-body/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], } } diff --git a/index.ts b/index.ts index f16c3f7..23975de 100644 --- a/index.ts +++ b/index.ts @@ -1,50 +1,55 @@ -import { createClient } from "redis"; -import http from "http"; - +import express from "express"; import prismaDB from "./lib/prisma"; import { splitContentIntoChunks } from "./lib/utils"; import { generateObject, generateText } from "ai"; import { openai } from "./lib/ai/available-models"; import { z } from "zod"; +import { redisClient } from "./lib/redis"; + +const app = express(); +app.use(express.json()); + +// -------------------------------------- +// 🔹 Zod Schema for Submission Validation +// -------------------------------------- +const submissionSchema = z.object({ + id: z.string(), + brokerage: z.string(), + firstName: z.string(), + lastName: z.string(), + tags: z.array(z.string()), + email: z.string().email(), + linkedinUrl: z.string(), + workPhone: z.string(), + dealCaption: z.string(), + revenue: z.number(), + ebitda: z.number(), + title: z.string(), + dealTeaser: z.string().nullable(), + grossRevenue: z.number().nullable(), + askingPrice: z.number().nullable(), + ebitdaMargin: z.number(), + industry: z.string(), + dealType: z.string(), + sourceWebsite: z.string(), + companyLocation: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + bitrixLink: z.string().nullable(), + status: z.string(), + isReviewed: z.boolean(), + isPublished: z.boolean(), + seen: z.boolean(), + bitrixId: z.string().nullable(), + bitrixCreatedAt: z.string().nullable(), + userId: z.string(), + screenerId: z.string(), + screenerContent: z.string(), + screenerName: z.string(), + jobId: z.string(), +}); -const QUEUE = "dealListings"; -const DONE_CHANNEL = "problem_done"; - -type Submission = { - id: string; - brokerage: string; - firstName: string; - lastName: string; - tags: string[]; - email: string; - linkedinUrl: string; - workPhone: string; - dealCaption: string; - revenue: number; - ebitda: number; - title: string; - dealTeaser: string | null; - grossRevenue: number | null; - askingPrice: number | null; - ebitdaMargin: number; - industry: string; - dealType: string; - sourceWebsite: string; - companyLocation: string; - createdAt: string; - updatedAt: string; - bitrixLink: string | null; - status: string; - isReviewed: boolean; - isPublished: boolean; - seen: boolean; - bitrixId: string | null; - bitrixCreatedAt: string | null; - userId: string; - screenerId: string; - screenerContent: string; - screenerName: string; -}; +type Submission = z.infer; interface AIScreeningResult { title: string; @@ -53,17 +58,48 @@ interface AIScreeningResult { explanation: string; } -/** - * Generate final AI screening result - */ +// -------------------------------------- +// 🔹 Helper: Send notification to WebSocket service with retry logic +// -------------------------------------- +async function sendNotification(userId: string, title: string, status: string) { + const url = `${process.env.WEBSOCKET_URL}/notify`; + const body = { userId, title, status }; + + for (let attempt = 1; attempt <= 2; attempt++) { + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + console.log(`🔔 Notification sent → ${title} (${status})`); + return; + } catch (error) { + console.error(`❌ Failed to send notification (attempt ${attempt}):`, error); + if (attempt === 2) { + console.error("❌ Notification could not be delivered after 2 attempts"); + } else { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + } +} + +// -------------------------------------- +// 🔹 Generate final summarized AI screening +// -------------------------------------- async function generateFinalSummary( combinedSummary: string ): Promise { try { console.log("Generating final AI screening result..."); + const result = await generateObject({ model: openai("gpt-4o-mini"), - prompt: `Combine the following summaries into a single summary: ${combinedSummary}`, + prompt: `Combine the following summaries into a single structured summary: ${combinedSummary}`, schema: z.object({ title: z.string(), score: z.number(), @@ -71,10 +107,7 @@ async function generateFinalSummary( explanation: z.string(), }), }); - console.log( - "Final AI screening result generated successfully:", - result.object - ); + return result.object; } catch (error) { console.error("Error generating final summary:", error); @@ -82,61 +115,37 @@ async function generateFinalSummary( } } -/** - * Save AI screening result to database - */ +// -------------------------------------- +// 🔹 Save AI screening result in database +// -------------------------------------- async function saveAIScreeningResult( submissionId: string, result: AIScreeningResult, combinedSummary: string -): Promise { - try { - console.log( - `Saving AI screening result to database for submission: ${submissionId}` - ); - await prismaDB.aiScreening.create({ - data: { - dealId: submissionId, - title: result.title, - explanation: result.explanation, - score: result.score, - sentiment: result.sentiment, - content: combinedSummary, - }, - }); - console.log("AI screening result saved successfully to database"); - return true; - } catch (error) { - console.error("Error saving AI screening result:", error); - return false; - } +): Promise { + await prismaDB.aiScreening.create({ + data: { + dealId: submissionId, + title: result.title, + explanation: result.explanation, + score: result.score, + sentiment: result.sentiment, + content: combinedSummary, + }, + }); } -/** - * Process content chunks and generate summaries - */ +// -------------------------------------- +// 🔹 Process text chunks using OpenAI +// -------------------------------------- async function processContentChunks( chunks: string[], dealInfo: Submission ): Promise { - console.log( - `Processing ${chunks.length} content chunks for deal: ${dealInfo.id}` - ); const summaries: string[] = []; for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; - if (!chunk) { - console.warn(`Chunk ${i + 1} is undefined, skipping...`); - continue; - } try { - console.log( - `Processing chunk ${i + 1}/${chunks.length} (${ - chunk.length - } characters)` - ); - const dealContext = { title: dealInfo.title, brokerage: dealInfo.brokerage, @@ -146,25 +155,21 @@ async function processContentChunks( ebitdaMargin: dealInfo.ebitdaMargin, companyLocation: dealInfo.companyLocation, revenue: dealInfo.revenue, - caption: dealInfo.dealCaption, industry: dealInfo.industry, }; - const prompt = `Based on this deal context: ${JSON.stringify( + const prompt = `Given this deal context: ${JSON.stringify( dealContext - )}, evaluate the following text: ${chunk}`; + )}, analyze the following section: ${chunks[i]}`; const summary = await generateText({ system: - "You are an expert AI Assistant that specializes in deal sourcing, evaluation and private equity in general", + "You are an expert AI Assistant that specializes in deal sourcing, evaluation, and private equity.", model: openai("gpt-4o-mini"), prompt, }); - console.log("summar generated by AI", summary.text); - summaries.push(summary.text); - console.log(`Chunk ${i + 1} processed successfully`); } catch (error) { console.error(`Error processing chunk ${i + 1}:`, error); summaries.push(`[Error processing chunk]`); @@ -174,182 +179,122 @@ async function processContentChunks( return summaries; } -/** - * Process a single submission - */ -async function processSubmission(submission: Submission): Promise { - try { - console.log(`=== Starting to process submission: ${submission.id} ===`); +// -------------------------------------- +// 🔹 Process an incoming submission end-to-end +// -------------------------------------- +async function processSubmission(submission: Submission): Promise { + console.log(`Processing submission: ${submission.jobId}`); + + const redisKey = `deal:${submission.jobId}`; + await redisClient.hset(redisKey, { status: "Processing" }); - // Split content into chunks - console.log("Splitting content into chunks..."); + try { const chunks = await splitContentIntoChunks(submission.screenerContent); - console.log(`Content split into ${chunks.length} chunks`); if (chunks.length === 0) { - console.warn("No content chunks generated - submission will be skipped"); - return false; + console.warn("No content chunks generated for submission"); + await redisClient.hset(redisKey, { status: "Done", result: "Failed" }); + await sendNotification(submission.userId, submission.title, "Failed"); + return; } - // Process chunks - console.log("Processing content chunks..."); const summaries = await processContentChunks(chunks, submission); const combinedSummary = summaries.join("\n\n=== Next Section ===\n\n"); - console.log( - `Combined summary length: ${combinedSummary.length} characters` - ); - // Generate final result - console.log("Generating final AI screening result..."); const finalResult = await generateFinalSummary(combinedSummary); if (!finalResult) { - console.error( - "Failed to generate final summary - submission processing failed" - ); - return false; + console.error("Failed to generate final AI result"); + await redisClient.hset(redisKey, { status: "Done", result: "Failed" }); + await sendNotification(submission.userId, submission.title, "Failed"); + return; } - // Save to database - console.log("Saving result to database..."); - const saveSuccess = await saveAIScreeningResult( - submission.id, - finalResult, - combinedSummary - ); + await saveAIScreeningResult(submission.id, finalResult, combinedSummary); - if (saveSuccess) { - console.log( - "Database save successful, publishing completion notification..." - ); - console.log(`=== Submission ${submission.id} processed successfully ===`); - return true; - } else { - console.error("Database save failed - submission processing incomplete"); - return false; - } - } catch (error) { - console.error(`=== Error processing submission ${submission.id}:`, error); - return false; + await redisClient.hset(redisKey, { status: "Done", result: "Success" }); + await sendNotification(submission.userId, submission.title, "Success"); + + console.log(`✅ Submission ${submission.jobId} processed successfully`); + } catch (err) { + console.error(`Error processing submission ${submission.jobId}:`, err); + await redisClient.hset(redisKey, { status: "Done", result: "Failed" }); + await sendNotification(submission.userId, submission.title, "Failed"); } } -async function start() { - const redis = createClient({ url: process.env.REDIS_URL }); - redis.on("error", (e) => console.error("Redis error", e)); - await redis.connect(); - // ✅ We still connect to Redis to publish "done" notifications - console.log("Worker connected to Redis for publishing completion."); +// -------------------------------------- +// 🔹 Pub/Sub handler → Triggered when new job is published +// -------------------------------------- +app.post("/", async (req, res) => { + try { + const pubsubMessage = req.body.message; + if (!pubsubMessage) return res.status(400).send("No message provided"); + + const dataStr = Buffer.from(pubsubMessage.data, "base64").toString(); + + // -------------------------------------- + // 🔹 Parse and validate payload with Zod + // -------------------------------------- + const parsed = submissionSchema.safeParse(JSON.parse(dataStr)); + if (!parsed.success) { + console.error("Invalid submission payload", parsed.error); + return res.status(400).send("Invalid payload"); + } + const payload: Submission = parsed.data; - const port = Number(process.env.PORT) || 8080; + console.log( + "Received Pub/Sub message:", + pubsubMessage.attributes?.jobType, + "→ Job ID:", + payload.jobId + ); - const server = http.createServer(async (req, res) => { - if (req.url === "/health") { - res.writeHead(200, { "Content-Type": "text/plain" }).end("OK"); - return; - } + await processSubmission(payload); - // ✅ This is the endpoint our Next.js app will "poke" - if (req.method === "POST" && req.url === "/process-queue") { - console.log("Received trigger. Starting to process Redis queue..."); - - try { - let itemsProcessed = 0; - // ✅ Loop until the queue is empty - while (true) { - const item = await redis.lPop(QUEUE); - - if (item) { - // If we got an item, process it - const submission: Submission = JSON.parse(item); - await processSubmission(submission); - itemsProcessed++; - } else { - // If item is null, the queue is empty, so we stop. - console.log("Queue is empty."); - break; - } - } - - res - .writeHead(200) - .end(`Processing complete. Items processed: ${itemsProcessed}`); - } catch (err) { - console.error("An error occurred while processing the queue:", err); - res.writeHead(500).end("Failed to process queue"); - } - return; + res.status(204).send(); + } catch (err) { + console.error("Error handling Pub/Sub message:", err); + res.status(500).send("Internal Server Error"); + } +}); + +// -------------------------------------- +// ✅ Test Notification Endpoint +// -------------------------------------- +app.post("/notify", async (req, res) => { + try { + const { userId, title, status } = req.body; + + if (!userId || !title) { + return res.status(400).json({ error: "Missing required fields" }); } - res.writeHead(404).end("Not Found"); - }); + console.log("📩 Received test /notify request:", req.body); - server.listen(port, () => - console.log(`Worker HTTP server listening for Pub/Sub events on :${port}`) - ); + // Publish message to Redis so your WebSocket worker receives it + await redisClient.publish( + "notifications", + JSON.stringify({ userId, title, status }) + ); - // ❌ REMOVED: The entire `while(true)` loop with `redis.brPop` is gone. -} -// async function start() { -// const redis = createClient({ url: process.env.REDIS_URL }); -// redis.on("error", (e) => console.error("Redis error", e)); -// await redis.connect(); -// console.log("Worker connected to Redis"); - -// // Start a simple HTTP server for Cloud Run health checks -// const port = Number(process.env.PORT) || 8080; -// const server = http.createServer((req, res) => { -// if (!req.url) return; -// if (req.url === "/health" || req.url === "/") { -// res.writeHead(200, { "Content-Type": "text/plain" }); -// res.end("OK"); -// } else { -// res.writeHead(404, { "Content-Type": "text/plain" }); -// res.end("Not Found"); -// } -// }); - -// server.listen(port, () => -// console.log(`Worker HTTP server listening on :${port}`) -// ); - -// while (true) { -// try { -// console.log("Waiting for items with BRPOP on:", QUEUE); -// const res = await redis.brPop(QUEUE, 0); -// if (!res) continue; -// const item = res.element; -// let submission: Submission; -// try { -// submission = JSON.parse(item); -// } catch { -// console.error("Invalid JSON item, skipping"); -// continue; -// } - -// console.log("Received and parsed submission", submission); - -// // Minimal "processing" step (replace with real work) - -// await processSubmission(submission); - -// // Publish completion notification -// await redis.publish( -// DONE_CHANNEL, -// JSON.stringify({ -// userId: submission.userId, -// productId: submission.id, -// status: "done", -// productName: submission.title || submission.id, -// }) -// ); -// } catch (e) { -// console.error("Worker loop error", e); -// await new Promise((r) => setTimeout(r, 1000)); -// } -// } -// } - -start().catch((e) => { - console.error("Worker failed to start", e); - process.exit(1); + res.status(200).json({ message: "Notification published to Redis" }); + } catch (err) { + console.error("Error in /notify:", err); + res.status(500).json({ error: "Internal Server Error" }); + } +}); + +// -------------------------------------- +// 🔹 Health check endpoint +// -------------------------------------- +app.get("/health", (_, res) => { + res.status(200).send("OK"); +}); + +// -------------------------------------- +// 🔹 Start Express server +// -------------------------------------- +const port = process.env.PORT || 8080; +app.listen(port, () => { + console.log(`Worker listening for Pub/Sub events on port ${port}`); }); diff --git a/lib/redis.ts b/lib/redis.ts new file mode 100644 index 0000000..7889ab9 --- /dev/null +++ b/lib/redis.ts @@ -0,0 +1,22 @@ +// import { createClient } from "redis"; +import { Redis } from "ioredis"; + +// export const redisClient = createClient({ +// url: process.env.REDIS_URL, +// }); + +export const redisClient = new Redis(process.env.REDIS_URL as string); + +export async function rateLimit( + keyBase: string, + max: number, + windowMs: number, +) { + const bucket = Math.floor(Date.now() / windowMs); + const key = `rl:${keyBase}:${bucket}`; + const count = await redisClient.incr(key); + if (count === 1) await redisClient.pexpire(key, windowMs); + const ok = count <= max; + const reset = (bucket + 1) * windowMs; + return { ok, remaining: Math.max(0, max - count), reset }; +} diff --git a/package.json b/package.json index d9ff1a1..3f2066f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "private": true, "devDependencies": { "@types/bun": "latest", + "@types/express": "^5.0.3", "prisma": "^6.12.0" }, "scripts": { @@ -25,6 +26,8 @@ "@prisma/client": "^6.12.0", "ai": "^5.0.24", "dotenv": "^17.2.1", + "express": "^5.1.0", + "ioredis": "^5.8.0", "openai": "^5.10.1", "redis": "^5.6.0", "zod": "^4.0.11"