update 9/20/25

This commit is contained in:
Ra
2025-09-20 21:44:02 -07:00
parent c713c2ed5e
commit b41f489fe6
58 changed files with 11529 additions and 2769 deletions

1
functions/.tool-versions Normal file
View File

@@ -0,0 +1 @@
direnv 2.37.1

View File

@@ -5,11 +5,13 @@
"name": "auditly-functions",
"dependencies": {
"@google-cloud/vertexai": "^1.10.0",
"firebase-admin": "^12.7.0",
"@neondatabase/serverless": "^0.9.5",
"firebase-functions": "^6.4.0",
"pg": "^8.12.0",
"stripe": "^18.5.0",
},
"devDependencies": {
"@types/pg": "^8.11.6",
"firebase-functions-test": "^3.4.1",
},
},
@@ -183,6 +185,8 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@neondatabase/serverless": ["@neondatabase/serverless@0.9.5", "", { "dependencies": { "@types/pg": "8.11.6" } }, "sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
@@ -259,6 +263,8 @@
"@types/node": ["@types/node@22.17.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w=="],
"@types/pg": ["@types/pg@8.15.5", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ=="],
"@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=="],
@@ -419,7 +425,7 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"dedent": ["dedent@1.6.0", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA=="],
@@ -773,6 +779,8 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="],
"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=="],
@@ -801,6 +809,24 @@
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
"pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="],
"pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="],
"pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="],
"pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="],
"pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -809,6 +835,16 @@
"pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="],
"pretty-format": ["pretty-format@30.0.5", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw=="],
"proto3-json-serializer": ["proto3-json-serializer@2.0.2", "", { "dependencies": { "protobufjs": "^7.2.5" } }, "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ=="],
@@ -871,6 +907,8 @@
"source-map-support": ["source-map-support@0.5.13", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
@@ -969,6 +1007,8 @@
"write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
@@ -979,12 +1019,16 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@babel/core/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/traverse/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"@google-cloud/storage/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
@@ -995,26 +1039,32 @@
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"@neondatabase/serverless/@types/pg": ["@types/pg@8.11.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"gaxios/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"google-gax/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"http-proxy-agent/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"https-proxy-agent/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"istanbul-lib-source-maps/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"jsonwebtoken/jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="],
"jwks-rsa/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
@@ -1023,8 +1073,6 @@
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
@@ -1045,18 +1093,22 @@
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"@neondatabase/serverless/@types/pg/pg-types": ["pg-types@4.1.0", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg=="],
"jsonwebtoken/jws/jwa": ["jwa@1.4.2", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"teeny-request/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"teeny-request/https-proxy-agent/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"@neondatabase/serverless/@types/pg/pg-types/postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
"@neondatabase/serverless/@types/pg/pg-types/postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="],
"@neondatabase/serverless/@types/pg/pg-types/postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="],
"@neondatabase/serverless/@types/pg/pg-types/postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="],
}
}

98
functions/database.js Normal file
View File

@@ -0,0 +1,98 @@
const { Pool } = require('pg');
const { neon } = require('@neondatabase/serverless');
// Database configuration
const DB_CONFIG = {
// Use Neon serverless for edge deployments
connectionString: process.env.DATABASE_URL || process.env.NEON_DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
};
// Create connection pool for traditional deployment
const pool = new Pool(DB_CONFIG);
// Create Neon serverless client for edge/serverless deployment
let neonClient = null;
if (DB_CONFIG.connectionString) {
neonClient = neon(DB_CONFIG.connectionString);
}
/**
* Get database client - either pool connection or Neon serverless
* @returns {Promise<any>} Database client
*/
async function getDbClient() {
if (process.env.USE_NEON_SERVERLESS === 'true' && neonClient) {
return neonClient;
}
return pool.connect();
}
/**
* Execute a query using the appropriate client
* @param {string} query - SQL query
* @param {any[]} params - Query parameters
* @returns {Promise<any>} Query result
*/
async function executeQuery(query, params = []) {
if (process.env.USE_NEON_SERVERLESS === 'true' && neonClient) {
// Neon serverless client
return await neonClient(query, params);
} else {
// Traditional pool connection
const client = await pool.connect();
try {
const result = await client.query(query, params);
return result.rows;
} finally {
client.release();
}
}
}
/**
* Execute a transaction
* @param {Function} callback - Function to execute within transaction
* @returns {Promise<any>} Transaction result
*/
async function executeTransaction(callback) {
if (process.env.USE_NEON_SERVERLESS === 'true' && neonClient) {
// For Neon serverless, we'll need to handle this differently
// Note: Neon serverless doesn't support traditional transactions
// Each query is automatically committed
return await callback(neonClient);
} else {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}
/**
* Close all database connections
*/
async function closeConnections() {
if (pool) {
await pool.end();
}
}
module.exports = {
pool,
neonClient,
getDbClient,
executeQuery,
executeTransaction,
closeConnections,
DB_CONFIG
};

View File

@@ -1,23 +1,12 @@
const { onRequest } = require("firebase-functions/v2/https");
const { setGlobalOptions, logger } = require("firebase-functions/v2");
const admin = require("firebase-admin");
const { VertexAI } = require('@google-cloud/vertexai');
const Stripe = require("stripe");
const { executeQuery, executeTransaction } = require('./database');
// Set global options for all functions to use us-central1 region
setGlobalOptions({ region: "us-central1" });
// const serviceAccount = require("./auditly-consulting-firebase-adminsdk-fbsvc-e4b51ef5cf.json");
const serviceAccount = require("./auditly-c0027-firebase-adminsdk-fbsvc-1db7c58141.json")
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
//region Interface Clients
const db = admin.firestore();
// Initialize Vertex AI with your project ID
// This automatically uses IAM authentication from the service account
const vertexAI = new VertexAI({
@@ -441,59 +430,78 @@ const RESPONSE_FORMAT_COMPANY = {
//endregion Constants
//region Helper Functions
const validateAuthAndGetContext = async (req, res) => {
const authHeader = req.headers.authorization;
async function validateAuthAndGetContext(req, res) {
// Set CORS headers for all requests
res.setHeader('Access-Control-Allow-Origin', '*')
.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
if (req.method == "OPTIONS") {
res.headers['Access-Control-Allow-Origin'] = '*';
res.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS';
res.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type';
if (req.method === "OPTIONS") {
res.status(204).send('');
return false;
return null;
}
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new Error('Missing or invalid authorization header');
res.status(401).json({ error: 'Missing or invalid authorization header' });
return null;
}
const token = authHeader.substring(7);
// Validate token format (should start with 'session_')
if (!token.startsWith('session_')) {
throw new Error('Invalid token format');
// Validate token format more thoroughly
if (!token.startsWith('session_') || token.length < 20) {
res.status(401).json({ error: 'Invalid token format' });
return null;
}
// Look up token in Firestore
const tokenDoc = await db.collection("authTokens").doc(token).get();
try {
// Look up token in PostgreSQL
const tokenRows = await executeQuery(
'SELECT * FROM auth_tokens WHERE token = $1 AND is_active = true',
[token]
);
if (!tokenDoc.exists) {
throw new Error('Token not found');
if (tokenRows.length === 0) {
res.status(401).json({ error: 'Token not found' });
return null;
}
const tokenData = tokenRows[0];
if (Date.now() > tokenData.expires_at) {
res.status(401).json({ error: 'Token has expired' });
return null;
}
// Update last used timestamp (don't await to avoid blocking)
executeQuery(
'UPDATE auth_tokens SET last_used_at = $1 WHERE token = $2',
[Date.now(), token]
).catch(error => {
console.warn('Failed to update token lastUsedAt:', error);
});
// Get user's organizations
const userOrgsRows = await executeQuery(
'SELECT organization_id FROM user_organizations WHERE user_id = $1',
[tokenData.user_id]
);
const orgIds = userOrgsRows.map(row => row.organization_id);
return {
userId: tokenData.user_id,
orgIds: orgIds,
orgId: orgIds[0] || null,
token: token
};
} catch (error) {
console.error('Auth validation error:', error);
res.status(500).json({ error: 'Authentication validation failed' });
return null;
}
const tokenData = tokenDoc.data();
if (!tokenData.isActive) {
throw new Error('Token is inactive');
}
if (Date.now() > tokenData.expiresAt) {
throw new Error('Token has expired');
}
// Update last used timestamp
await tokenDoc.ref.update({ lastUsedAt: Date.now() });
// Get user's organizations
const userOrgsSnapshot = await db.collection("users").doc(tokenData.userId).collection("organizations").get();
const orgIds = userOrgsSnapshot.docs.map(doc => doc.id);
return {
userId: tokenData.userId,
orgIds: orgIds,
// For backward compatibility, use first org as default
orgId: orgIds[0] || null,
token: token
};
};
const generateOTP = () => {
@@ -640,9 +648,18 @@ const verifyUserAuthorization = async (userId, orgId) => {
}
try {
// Check if user exists in the organization's employees collection
const employeeDoc = await db.collection("orgs").doc(orgId).collection("employees").doc(userId).get();
return employeeDoc.exists;
// Check if user exists in the organization's employees or as an organization member
const employeeRows = await executeQuery(
'SELECT id FROM employees WHERE organization_id = $1 AND (id = $2 OR user_id = $2)',
[orgId, userId]
);
const userOrgRows = await executeQuery(
'SELECT user_id FROM user_organizations WHERE user_id = $1 AND organization_id = $2',
[userId, orgId]
);
return employeeRows.length > 0 || userOrgRows.length > 0;
} catch (error) {
console.error("Authorization check error:", error);
return false;
@@ -667,14 +684,19 @@ exports.sendOTP = onRequest({cors: true}, async (req, res) => {
const otp = generateOTP();
const expiresAt = Date.now() + (5 * 60 * 1000); // 5 minutes expiry
// Store OTP in Firestore
await db.collection("otps").doc(email).set({
otp,
expiresAt,
attempts: 0,
inviteCode: inviteCode || null,
createdAt: Date.now(),
});
// Store OTP in PostgreSQL
await executeQuery(
`INSERT INTO otps (email, otp, expires_at, attempts, invite_code, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (email)
DO UPDATE SET
otp = EXCLUDED.otp,
expires_at = EXCLUDED.expires_at,
attempts = 0,
invite_code = EXCLUDED.invite_code,
created_at = EXCLUDED.created_at`,
[email, otp, expiresAt, 0, inviteCode || null, Date.now()]
);
// In production, send actual email
console.log(`📧 OTP for ${email}: ${otp} (expires in 5 minutes)`);
@@ -705,56 +727,59 @@ exports.verifyOTP = onRequest({cors: true}, async (req, res) => {
}
try {
// Retrieve OTP document
const otpDoc = await db.collection("otps").doc(email).get();
// Retrieve OTP from PostgreSQL
const otpRows = await executeQuery(
'SELECT * FROM otps WHERE email = $1',
[email]
);
if (!otpDoc.exists) {
if (otpRows.length === 0) {
return res.status(400).json({ error: "Invalid verification code" });
}
const otpData = otpDoc.data();
const otpData = otpRows[0];
// Check if OTP is expired
if (Date.now() > otpData.expiresAt) {
await otpDoc.ref.delete();
if (Date.now() > otpData.expires_at) {
await executeQuery('DELETE FROM otps WHERE email = $1', [email]);
return res.status(400).json({ error: "Verification code has expired" });
}
// Check if too many attempts
if (otpData.attempts >= 5) {
await otpDoc.ref.delete();
await executeQuery('DELETE FROM otps WHERE email = $1', [email]);
return res.status(400).json({ error: "Too many failed attempts" });
}
// Verify OTP
if (otpData.otp !== otp) {
await otpDoc.ref.update({
attempts: (otpData.attempts || 0) + 1,
});
await executeQuery(
'UPDATE otps SET attempts = $1 WHERE email = $2',
[(otpData.attempts || 0) + 1, email]
);
return res.status(400).json({ error: "Invalid verification code" });
}
// OTP is valid - clean up and create/find user
await otpDoc.ref.delete();
await executeQuery('DELETE FROM otps WHERE email = $1', [email]);
// Generate a unique user ID for this email if it doesn't exist
let userId;
let userDoc;
let userExists = false;
// Check if user already exists by email
const existingUserQuery = await db.collection("users")
.where("email", "==", email)
.limit(1)
.get();
const existingUserRows = await executeQuery(
'SELECT id FROM users WHERE email = $1 LIMIT 1',
[email]
);
if (!existingUserQuery.empty) {
if (existingUserRows.length > 0) {
// User exists, get their ID
userDoc = existingUserQuery.docs[0];
userId = userDoc.id;
userId = existingUserRows[0].id;
userExists = true;
} else {
// Create new user
userId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
userDoc = null;
}
// Prepare user object for response
@@ -765,53 +790,52 @@ exports.verifyOTP = onRequest({cors: true}, async (req, res) => {
emailVerified: true,
};
// Create or update user document in Firestore
const userRef = db.collection("users").doc(userId);
const userData = {
id: userId,
email: email,
displayName: email.split("@")[0],
emailVerified: true,
lastLoginAt: Date.now(),
};
if (!userDoc) {
// Create or update user document in PostgreSQL
const currentTime = Date.now();
if (!userExists) {
// Create new user document
userData.createdAt = Date.now();
await userRef.set(userData);
await executeQuery(
`INSERT INTO users (id, email, display_name, email_verified, created_at, last_login_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[userId, email, email.split("@")[0], true, currentTime, currentTime]
);
} else {
// Update existing user with latest login info
await userRef.update({
lastLoginAt: Date.now(),
});
await executeQuery(
'UPDATE users SET last_login_at = $1 WHERE id = $2',
[currentTime, userId]
);
}
// Generate a simple session token (in production, use proper JWT)
const customToken = `session_${userId}_${Date.now()}`;
// Store auth token in Firestore for validation
await db.collection("authTokens").doc(customToken).set({
userId: userId,
createdAt: Date.now(),
expiresAt: Date.now() + (30 * 24 * 60 * 60 * 1000), // 30 days
lastUsedAt: Date.now(),
isActive: true
});
// Store auth token in PostgreSQL for validation
await executeQuery(
`INSERT INTO auth_tokens (token, user_id, created_at, expires_at, last_used_at, is_active)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
customToken,
userId,
currentTime,
currentTime + (30 * 24 * 60 * 60 * 1000), // 30 days
currentTime,
true
]
);
// Handle invitation if present
let inviteData = null;
if (otpData.inviteCode) {
if (otpData.invite_code) {
try {
const inviteDoc = await db
.collectionGroup("invites")
.where("code", "==", otpData.inviteCode)
.where("status", "==", "pending")
.limit(1)
.get();
const inviteRows = await executeQuery(
'SELECT * FROM invites WHERE code = $1 AND status = $2 LIMIT 1',
[otpData.invite_code, 'pending']
);
if (!inviteDoc.empty) {
inviteData = inviteDoc.docs[0].data();
if (inviteRows.length > 0) {
inviteData = inviteRows[0];
}
} catch (error) {
console.error("Error fetching invite:", error);
@@ -833,9 +857,8 @@ exports.verifyOTP = onRequest({cors: true}, async (req, res) => {
//region Create Invitation
exports.createInvitation = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -844,8 +867,6 @@ exports.createInvitation = onRequest({cors: true}, async (req, res) => {
}
try {
// Validate auth token and get user context
const authContext = await validateAuthAndGetContext(req, res);
const { name, email, role = "employee", department } = req.body;
if (!email || !name) {
@@ -925,7 +946,7 @@ exports.createInvitation = onRequest({cors: true}, async (req, res) => {
},
},
],
from: { email: 'no-reply@auditly.com', name: 'Auditly' },
from: { email: 'no-reply@orbitly.com', name: 'Orbitly' },
template_id: process.env.SENDGRID_TEMPLATE_ID,
}),
});
@@ -1196,6 +1217,9 @@ exports.submitEmployeeAnswers = onRequest({cors: true}, async (req, res) => {
} else {
// Authenticated submission
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
if (!employeeId || !answers) {
return res.status(400).json({ error: "Employee ID and answers are required for authenticated submissions" });
@@ -1391,9 +1415,8 @@ Be thorough, professional, and focus on actionable insights.
//region Generate Employee Report
exports.generateEmployeeReport = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -1511,8 +1534,8 @@ Be thorough, professional, and focus on actionable insights.
//region Generate Company Report
exports.generateCompanyReport = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -1520,8 +1543,6 @@ exports.generateCompanyReport = onRequest({cors: true}, async (req, res) => {
return res.status(405).json({ error: "Method not allowed" });
}
const authContext = await validateAuthAndGetContext(req, res);
const orgId = authContext.orgId;
if (!orgId) {
return res.status(400).json({ error: "User has no associated organizations" });
@@ -1646,9 +1667,8 @@ Be thorough, professional, and focus on actionable insights.`;
//region Chat
exports.chat = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -1739,8 +1759,8 @@ Instructions:
//region Create Organization
exports.createOrganization = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -1749,90 +1769,63 @@ exports.createOrganization = onRequest({cors: true}, async (req, res) => {
}
try {
// Validate auth token and get user context
const authContext = await validateAuthAndGetContext(req, res);
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: "Organization name is required" });
}
// Generate unique organization ID
const orgId = `org_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const currentTime = Date.now();
// Create comprehensive organization document
const orgData = {
name,
createdAt: Date.now(),
updatedAt: Date.now(),
onboardingCompleted: false,
ownerId: authContext.userId,
// Subscription fields (will be populated after Stripe setup)
subscription: {
status: 'trial', // trial, active, past_due, canceled
stripeCustomerId: null,
stripeSubscriptionId: null,
currentPeriodStart: null,
currentPeriodEnd: null,
trialEnd: Date.now() + (14 * 24 * 60 * 60 * 1000), // 14 day trial
},
// Usage tracking
usage: {
employeeCount: 0,
reportsGenerated: 0,
lastReportGeneration: null,
},
// Organization settings
settings: {
allowedEmployeeCount: 50, // Default limit
featuresEnabled: {
aiReports: true,
chat: true,
analytics: true,
}
await executeTransaction(async (client) => {
// Create comprehensive organization document
if (process.env.USE_NEON_SERVERLESS === 'true') {
await client(
`INSERT INTO organizations (
id, name, owner_id, onboarding_completed,
subscription_status, trial_end, employee_count, reports_generated,
allowed_employee_count, features_enabled, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
orgId, name, authContext.userId, false,
'trial', currentTime + (14 * 24 * 60 * 60 * 1000), // 14 day trial
0, 0, 50,
JSON.stringify({"aiReports": true, "chat": true, "analytics": true}),
currentTime, currentTime
]
);
// Add organization to user's organizations (for multi-org support)
await client(
`INSERT INTO user_organizations (user_id, organization_id, role, onboarding_completed, joined_at)
VALUES ($1, $2, $3, $4, $5)`,
[authContext.userId, orgId, 'owner', false, currentTime]
);
} else {
await client.query(
`INSERT INTO organizations (
id, name, owner_id, onboarding_completed,
subscription_status, trial_end, employee_count, reports_generated,
allowed_employee_count, features_enabled, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
orgId, name, authContext.userId, false,
'trial', currentTime + (14 * 24 * 60 * 60 * 1000), // 14 day trial
0, 0, 50,
JSON.stringify({"aiReports": true, "chat": true, "analytics": true}),
currentTime, currentTime
]
);
// Add organization to user's organizations (for multi-org support)
await client.query(
`INSERT INTO user_organizations (user_id, organization_id, role, onboarding_completed, joined_at)
VALUES ($1, $2, $3, $4, $5)`,
[authContext.userId, orgId, 'owner', false, currentTime]
);
}
};
const orgRef = db.collection("orgs").doc(orgId);
await orgRef.set(orgData);
// Get user information from Firestore (since we don't use Firebase Auth)
const userRef = db.collection("users").doc(authContext.userId);
const userDoc = await userRef.get();
if (!userDoc.exists) {
console.error("User document not found:", authContext.userId);
return res.status(400).json({ error: "User not found" });
}
const userData = userDoc.data();
// Add owner info to organization document (owners are NOT employees)
const ownerInfo = {
id: authContext.userId,
name: userData.displayName || userData.email.split("@")[0],
email: userData.email,
joinedAt: Date.now()
};
// Update org document with owner info
await orgRef.update({
ownerInfo: ownerInfo,
updatedAt: Date.now()
});
// Add organization to user's organizations (for multi-org support)
const userOrgRef = db.collection("users").doc(authContext.userId).collection("organizations").doc(orgId);
await userOrgRef.set({
orgId,
name,
role: "owner",
onboardingCompleted: false,
joinedAt: Date.now(),
});
// Update user document with latest activity
await userRef.update({
lastLoginAt: Date.now(),
});
res.json({
@@ -1841,9 +1834,12 @@ exports.createOrganization = onRequest({cors: true}, async (req, res) => {
name,
role: "owner",
onboardingCompleted: false,
joinedAt: Date.now(),
subscription: orgData.subscription,
requiresSubscription: true, // Signal frontend to show subscription flow
joinedAt: currentTime,
subscription: {
status: 'trial',
trialEnd: currentTime + (14 * 24 * 60 * 60 * 1000)
},
requiresSubscription: true,
});
} catch (error) {
console.error("Create organization error:", error);
@@ -1853,17 +1849,9 @@ exports.createOrganization = onRequest({cors: true}, async (req, res) => {
//endregion Create Organization
//region Get Organizations
exports.getUserOrganizations = onRequest(async (req, res) => {
let authContext;
try {
authContext = await validateAuthAndGetContext(req, res);
} catch (error) {
logger.debug("Auth validation failed:", error);
return;
}
if (req.method === 'OPTIONS') {
res.status(204).send('');
exports.getUserOrganizations = onRequest({cors: true}, async (req, res) => {
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -1872,8 +1860,6 @@ exports.getUserOrganizations = onRequest(async (req, res) => {
}
try {
// Validate auth token and get user context
// Get user's organizations
const userOrgsSnapshot = await db
.collection("users")
@@ -1906,8 +1892,8 @@ exports.getUserOrganizations = onRequest(async (req, res) => {
//region Join Organization
exports.joinOrganization = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -1916,8 +1902,6 @@ exports.joinOrganization = onRequest({cors: true}, async (req, res) => {
}
try {
// Validate auth token and get user context
const authContext = await validateAuthAndGetContext(req, res);
const { inviteCode } = req.body;
if (!inviteCode) {
@@ -2167,7 +2151,7 @@ exports.joinOrganization = onRequest({cors: true}, async (req, res) => {
// res.status(500).json({ error: 'Webhook handler failed' });
// }
// });
//endregion Stripe Webhook
//#endregion Stripe Webhook
//region Get Sub Status
// exports.getSubscriptionStatus = onRequest(async (req, res) => {
@@ -2228,7 +2212,7 @@ exports.joinOrganization = onRequest({cors: true}, async (req, res) => {
// res.status(500).json({ error: "Failed to get subscription status" });
// }
// });
//endregion Get Sub Status
//#endregion Get Sub Status
//region Save Company Report
// exports.saveCompanyReport = onRequest(async (req, res) => {
@@ -2272,12 +2256,12 @@ exports.joinOrganization = onRequest({cors: true}, async (req, res) => {
// res.status(500).json({ error: "Failed to save company report" });
// }
// });
//endregion Save Company Report
//#endregion Save Company Report
//region Get Org Data
exports.getOrgData = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -2286,21 +2270,34 @@ exports.getOrgData = onRequest({cors: true}, async (req, res) => {
}
try {
// Validate auth token and get user context
const authContext = await validateAuthAndGetContext(req, res);
const orgId = authContext.orgId;
if (!orgId) {
return res.status(400).json({ error: "User has no associated organizations" });
}
// Get organization data
const orgDoc = await db.collection("orgs").doc(orgId).get();
if (!orgDoc.exists) {
const orgRows = await executeQuery(
'SELECT * FROM organizations WHERE id = $1',
[orgId]
);
if (orgRows.length === 0) {
return res.status(404).json({ error: "Organization not found" });
}
const orgData = { id: orgId, ...orgDoc.data() };
const orgData = {
id: orgRows[0].id,
name: orgRows[0].name,
industry: orgRows[0].industry,
description: orgRows[0].description,
mission: orgRows[0].mission,
vision: orgRows[0].vision,
values: orgRows[0].values,
onboardingCompleted: orgRows[0].onboarding_completed,
onboardingData: orgRows[0].onboarding_data,
createdAt: orgRows[0].created_at,
updatedAt: orgRows[0].updated_at
};
res.json({
success: true,
@@ -2308,10 +2305,6 @@ exports.getOrgData = onRequest({cors: true}, async (req, res) => {
});
} catch (error) {
console.error("Get org data error:", error);
if (error.message.includes('Missing or invalid authorization') ||
error.message.includes('Token')) {
return res.status(401).json({ error: error.message });
}
res.status(500).json({ error: "Failed to get organization data" });
}
});
@@ -2319,8 +2312,8 @@ exports.getOrgData = onRequest({cors: true}, async (req, res) => {
//region Update Organization Data
exports.updateOrgData = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -2329,8 +2322,6 @@ exports.updateOrgData = onRequest({cors: true}, async (req, res) => {
}
try {
// Validate auth token and get user context
const authContext = await validateAuthAndGetContext(req, res);
const { data } = req.body;
if (!data) {
@@ -2366,8 +2357,8 @@ exports.updateOrgData = onRequest({cors: true}, async (req, res) => {
//region Get Employees
exports.getEmployees = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -2376,9 +2367,6 @@ exports.getEmployees = onRequest({cors: true}, async (req, res) => {
}
try {
// Validate auth token and get user context
const authContext = await validateAuthAndGetContext(req, res);
const orgId = authContext.orgId;
if (!orgId) {
return res.status(400).json({ error: "User has no associated organizations" });
@@ -2413,8 +2401,8 @@ exports.getEmployees = onRequest({cors: true}, async (req, res) => {
//region Get Submissions
exports.getSubmissions = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -2423,9 +2411,6 @@ exports.getSubmissions = onRequest({cors: true}, async (req, res) => {
}
try {
// Validate auth token and get user context
const authContext = await validateAuthAndGetContext(req, res);
const orgId = authContext.orgId;
if (!orgId) {
return res.status(400).json({ error: "User has no associated organizations" });
@@ -2456,8 +2441,8 @@ exports.getSubmissions = onRequest({cors: true}, async (req, res) => {
//region Get Reports
exports.getReports = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -2466,9 +2451,6 @@ exports.getReports = onRequest({cors: true}, async (req, res) => {
}
try {
// Validate auth token and get user context
const authContext = await validateAuthAndGetContext(req, res);
const orgId = authContext.orgId;
if (!orgId) {
return res.status(400).json({ error: "User has no associated organizations" });
@@ -2552,8 +2534,8 @@ exports.getReports = onRequest({cors: true}, async (req, res) => {
//region Save Report
exports.saveReport = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -2568,12 +2550,6 @@ exports.saveReport = onRequest({cors: true}, async (req, res) => {
}
try {
// Verify user authorization
const isAuthorized = await verifyUserAuthorization(userId, orgId);
if (!isAuthorized) {
return res.status(403).json({ error: "Unauthorized access to organization" });
}
// Add metadata
const currentTime = Date.now();
if (!reportData.id) {
@@ -2603,8 +2579,8 @@ exports.saveReport = onRequest({cors: true}, async (req, res) => {
//region Get Company Reports
exports.getCompanyReports = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -2613,8 +2589,6 @@ exports.getCompanyReports = onRequest({cors: true}, async (req, res) => {
}
try {
const authContext = await validateAuthAndGetContext(req, res);
const orgId = authContext.orgId;
if (!orgId) {
return res.status(400).json({ error: "User has no associated organizations" });
@@ -2646,8 +2620,8 @@ exports.getCompanyReports = onRequest({cors: true}, async (req, res) => {
//region Upload Image
exports.uploadImage = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
@@ -2655,19 +2629,14 @@ exports.uploadImage = onRequest({cors: true}, async (req, res) => {
return res.status(405).json({ error: "Method not allowed" });
}
const { orgId, userId, imageData } = req.body;
const { orgId, userId } = authContext;
const { imageData } = req.body;
if (!orgId || !userId || !imageData) {
return res.status(400).json({ error: "Organization ID, user ID, and image data are required" });
}
try {
// Verify user authorization
const isAuthorized = await verifyUserAuthorization(userId, orgId);
if (!isAuthorized) {
return res.status(403).json({ error: "Unauthorized access to organization" });
}
// Validate image data
const { collectionName, documentId, dataUrl, filename, originalSize, compressedSize, width, height } = imageData;
@@ -2764,7 +2733,7 @@ exports.uploadImage = onRequest({cors: true}, async (req, res) => {
// res.status(500).json({ error: "Failed to get image" });
// }
// });
//endregion Get Image
//#endregion Get Image
//region Delete Image
// exports.deleteImage = onRequest(async (req, res) => {
@@ -2809,4 +2778,4 @@ exports.uploadImage = onRequest({cors: true}, async (req, res) => {
// res.status(500).json({ error: "Failed to delete image" });
// }
// });
//endregion Delete Image
//#endregion Delete Image

View File

@@ -0,0 +1,358 @@
// Complete Migration Helper Script for remaining functions
// This script contains the migrated versions of key functions
const { executeQuery, executeTransaction } = require('./database');
/**
* Get User Organizations - Migrated to PostgreSQL
*/
exports.getUserOrganizations = onRequest({cors: true}, async (req, res) => {
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
// Get user's organizations
const userOrgsRows = await executeQuery(
`SELECT uo.organization_id as orgId, uo.role, uo.joined_at as joinedAt,
uo.onboarding_completed as onboardingCompleted, o.name
FROM user_organizations uo
JOIN organizations o ON uo.organization_id = o.id
WHERE uo.user_id = $1`,
[authContext.userId]
);
const organizations = userOrgsRows.map(row => ({
orgId: row.orgid,
name: row.name,
role: row.role,
onboardingCompleted: row.onboardingcompleted,
joinedAt: row.joinedat
}));
res.json({
success: true,
organizations,
});
} catch (error) {
console.error("Get user organizations error:", error);
res.status(500).json({ error: "Failed to get user organizations" });
}
});
/**
* Get Organization Data - Migrated to PostgreSQL
*/
exports.getOrgData = onRequest({cors: true}, async (req, res) => {
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const orgId = authContext.orgId;
if (!orgId) {
return res.status(400).json({ error: "User has no associated organizations" });
}
// Get organization data
const orgRows = await executeQuery(
'SELECT * FROM organizations WHERE id = $1',
[orgId]
);
if (orgRows.length === 0) {
return res.status(404).json({ error: "Organization not found" });
}
const orgData = {
id: orgRows[0].id,
name: orgRows[0].name,
industry: orgRows[0].industry,
description: orgRows[0].description,
mission: orgRows[0].mission,
vision: orgRows[0].vision,
values: orgRows[0].values,
onboardingCompleted: orgRows[0].onboarding_completed,
onboardingData: orgRows[0].onboarding_data,
createdAt: orgRows[0].created_at,
updatedAt: orgRows[0].updated_at
};
res.json({
success: true,
org: orgData
});
} catch (error) {
console.error("Get org data error:", error);
res.status(500).json({ error: "Failed to get organization data" });
}
});
/**
* Update Organization Data - Migrated to PostgreSQL
*/
exports.updateOrgData = onRequest({cors: true}, async (req, res) => {
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
if (req.method !== "PUT") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { data } = req.body;
if (!data) {
return res.status(400).json({ error: "Data is required" });
}
const orgId = authContext.orgId;
if (!orgId) {
return res.status(400).json({ error: "User has no associated organizations" });
}
// Build dynamic update query
const updateFields = [];
const values = [];
let paramCount = 1;
for (const [key, value] of Object.entries(data)) {
// Convert camelCase to snake_case for database columns
const dbKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
updateFields.push(`${dbKey} = $${paramCount}`);
values.push(typeof value === 'object' ? JSON.stringify(value) : value);
paramCount++;
}
if (updateFields.length === 0) {
return res.status(400).json({ error: "No valid fields to update" });
}
updateFields.push(`updated_at = $${paramCount}`);
values.push(Date.now());
values.push(orgId); // for WHERE clause
const query = `UPDATE organizations SET ${updateFields.join(', ')} WHERE id = $${paramCount + 1}`;
await executeQuery(query, values);
res.json({
success: true,
message: "Organization data updated successfully"
});
} catch (error) {
console.error("Update org data error:", error);
res.status(500).json({ error: "Failed to update organization data" });
}
});
/**
* Get Employees - Migrated to PostgreSQL
*/
exports.getEmployees = onRequest({cors: true}, async (req, res) => {
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const orgId = authContext.orgId;
if (!orgId) {
return res.status(400).json({ error: "User has no associated organizations" });
}
// Get all employees
const employeesRows = await executeQuery(
'SELECT * FROM employees WHERE organization_id = $1 AND status != $2 ORDER BY name',
[orgId, 'deleted']
);
const employees = employeesRows.map(row => ({
id: row.id,
name: row.name,
email: row.email,
role: row.role,
department: row.department,
status: row.status,
joinedAt: row.joined_at,
createdAt: row.created_at
}));
res.json({
success: true,
employees
});
} catch (error) {
console.error("Get employees error:", error);
res.status(500).json({ error: "Failed to get employees" });
}
});
/**
* Create Invitation - Migrated to PostgreSQL
*/
exports.createInvitation = onRequest({cors: true}, async (req, res) => {
const authContext = await validateAuthAndGetContext(req, res);
if (!authContext) {
return;
}
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { name, email, role = "employee", department } = req.body;
if (!email || !name) {
return res.status(400).json({ error: "Name and email are required" });
}
const orgId = authContext.orgId;
if (!orgId) {
return res.status(400).json({ error: "User has no associated organizations" });
}
// Generate invite code
const code = Math.random().toString(36).substring(2, 15);
// Generate employee ID
const employeeId = `emp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const currentTime = Date.now();
// Create employee data
const employeeData = {
id: employeeId,
name: name.trim(),
email: email.trim(),
role: role?.trim() || "employee",
department: department?.trim() || "General",
status: "invited",
inviteCode: code
};
await executeTransaction(async (client) => {
// Store invitation
if (process.env.USE_NEON_SERVERLESS === 'true') {
await client(
`INSERT INTO invites (code, organization_id, email, employee_data, status, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
code, orgId, email, JSON.stringify(employeeData), 'pending',
currentTime, currentTime + (7 * 24 * 60 * 60 * 1000) // 7 days
]
);
// Create employee record
await client(
`INSERT INTO employees (id, organization_id, name, email, role, department, status, invite_code, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[employeeId, orgId, name.trim(), email.trim(), role || 'employee', department || 'General', 'invited', code, currentTime]
);
} else {
await client.query(
`INSERT INTO invites (code, organization_id, email, employee_data, status, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
code, orgId, email, JSON.stringify(employeeData), 'pending',
currentTime, currentTime + (7 * 24 * 60 * 60 * 1000) // 7 days
]
);
// Create employee record
await client.query(
`INSERT INTO employees (id, organization_id, name, email, role, department, status, invite_code, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[employeeId, orgId, name.trim(), email.trim(), role || 'employee', department || 'General', 'invited', code, currentTime]
);
}
});
// Generate invite links
const baseUrl = process.env.CLIENT_URL || 'https://auditly-one.vercel.app';
const inviteLink = `${baseUrl}/#/employee-form/${code}`;
console.log(`📧 Invite link: ${inviteLink}`);
res.json({
success: true,
code,
employee: employeeData,
inviteLink,
message: "Invitation sent successfully",
});
} catch (error) {
console.error("Create invitation error:", error);
res.status(500).json({ error: "Failed to create invitation" });
}
});
/**
* Get Invitation Status - Migrated to PostgreSQL
*/
exports.getInvitationStatus = onRequest({cors: true}, async (req, res) => {
if (req.method === 'OPTIONS') {
res.status(204).send('');
return;
}
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
const { code } = req.query;
if (!code) {
return res.status(400).json({ error: "Invitation code is required" });
}
try {
const inviteRows = await executeQuery(
'SELECT * FROM invites WHERE code = $1',
[code]
);
if (inviteRows.length === 0) {
return res.status(404).json({ error: "Invitation not found" });
}
const invite = inviteRows[0];
// Check if expired
if (Date.now() > invite.expires_at) {
return res.status(400).json({ error: "Invitation has expired" });
}
res.json({
success: true,
used: invite.status !== 'pending',
employee: invite.employee_data,
invite: {
...invite,
employee: invite.employee_data
},
});
} catch (error) {
console.error("Get invitation status error:", error);
res.status(500).json({ error: "Failed to get invitation status" });
}
});
module.exports = {
// Export these functions so they can be used to replace the existing ones
};

View File

@@ -0,0 +1,227 @@
-- Initial migration to create all tables for the Auditly application
-- Migration: 001_create_initial_schema.sql
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(255) PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
display_name VARCHAR(255),
email_verified BOOLEAN DEFAULT false,
created_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
last_login_at BIGINT,
updated_at BIGINT DEFAULT EXTRACT(epoch FROM NOW()) * 1000
);
-- Create organizations table
CREATE TABLE IF NOT EXISTS organizations (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
owner_id VARCHAR(255) NOT NULL REFERENCES users(id),
industry VARCHAR(255),
size VARCHAR(255),
description TEXT,
mission TEXT,
vision TEXT,
values TEXT,
founding_year VARCHAR(255),
evolution TEXT,
major_milestones TEXT,
advantages TEXT,
vulnerabilities TEXT,
competitors TEXT,
market_position TEXT,
current_challenges TEXT,
short_term_goals TEXT,
long_term_goals TEXT,
key_metrics TEXT,
culture_description TEXT,
work_environment TEXT,
leadership_style TEXT,
communication_style TEXT,
additional_context TEXT,
onboarding_completed BOOLEAN DEFAULT false,
onboarding_data JSONB,
created_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
updated_at BIGINT DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
-- Subscription fields
subscription_status VARCHAR(50) DEFAULT 'trial',
stripe_customer_id VARCHAR(255),
stripe_subscription_id VARCHAR(255),
current_period_start BIGINT,
current_period_end BIGINT,
trial_end BIGINT,
-- Usage tracking
employee_count INTEGER DEFAULT 0,
reports_generated INTEGER DEFAULT 0,
last_report_generation BIGINT,
-- Settings
allowed_employee_count INTEGER DEFAULT 50,
features_enabled JSONB DEFAULT '{
"aiReports": true,
"chat": true,
"analytics": true
}'::jsonb
);
-- Create user_organizations junction table for multi-org support
CREATE TABLE IF NOT EXISTS user_organizations (
user_id VARCHAR(255) REFERENCES users(id) ON DELETE CASCADE,
organization_id VARCHAR(255) REFERENCES organizations(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL DEFAULT 'employee', -- owner, admin, employee
joined_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
onboarding_completed BOOLEAN DEFAULT false,
PRIMARY KEY (user_id, organization_id)
);
-- Create employees table
CREATE TABLE IF NOT EXISTS employees (
id VARCHAR(255) PRIMARY KEY,
organization_id VARCHAR(255) NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id VARCHAR(255) REFERENCES users(id) ON DELETE SET NULL, -- nullable for invite-only employees
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
role VARCHAR(255) DEFAULT 'employee',
department VARCHAR(255) DEFAULT 'General',
status VARCHAR(50) DEFAULT 'invited', -- invited, active, inactive
invite_code VARCHAR(255),
joined_at BIGINT DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
created_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
updated_at BIGINT DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
UNIQUE(organization_id, email)
);
-- Create auth_tokens table
CREATE TABLE IF NOT EXISTS auth_tokens (
token VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
expires_at BIGINT NOT NULL,
last_used_at BIGINT DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
is_active BOOLEAN DEFAULT true
);
-- Create otps table for email verification
CREATE TABLE IF NOT EXISTS otps (
email VARCHAR(255) PRIMARY KEY,
otp VARCHAR(6) NOT NULL,
expires_at BIGINT NOT NULL,
attempts INTEGER DEFAULT 0,
invite_code VARCHAR(255),
created_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000
);
-- Create invites table
CREATE TABLE IF NOT EXISTS invites (
code VARCHAR(255) PRIMARY KEY,
organization_id VARCHAR(255) NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
employee_data JSONB NOT NULL, -- Contains employee info like name, role, department
status VARCHAR(50) DEFAULT 'pending', -- pending, consumed, expired
created_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
expires_at BIGINT NOT NULL,
consumed_at BIGINT,
consumed_by VARCHAR(255)
);
-- Create submissions table
CREATE TABLE IF NOT EXISTS submissions (
id SERIAL PRIMARY KEY,
employee_id VARCHAR(255) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
organization_id VARCHAR(255) NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
answers JSONB NOT NULL,
status VARCHAR(50) DEFAULT 'completed',
submission_type VARCHAR(50) DEFAULT 'regular', -- regular, invite
invite_code VARCHAR(255),
submitted_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
created_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
UNIQUE(employee_id) -- One submission per employee
);
-- Create employee_reports table
CREATE TABLE IF NOT EXISTS employee_reports (
id SERIAL PRIMARY KEY,
employee_id VARCHAR(255) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
organization_id VARCHAR(255) NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
employee_name VARCHAR(255) NOT NULL,
role VARCHAR(255),
email VARCHAR(255),
summary TEXT,
submission_id INTEGER REFERENCES submissions(id),
company_context JSONB,
-- Report sections
role_and_output JSONB,
insights JSONB,
strengths TEXT[],
weaknesses TEXT[],
opportunities JSONB[],
risks TEXT[],
recommendations TEXT[],
grading_overview JSONB,
generated_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
created_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
updated_at BIGINT DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
UNIQUE(employee_id) -- One report per employee
);
-- Create company_reports table
CREATE TABLE IF NOT EXISTS company_reports (
id SERIAL PRIMARY KEY,
organization_id VARCHAR(255) NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
report_data JSONB NOT NULL, -- Contains the full company report structure
executive_summary TEXT,
generated_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
created_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000,
updated_at BIGINT DEFAULT EXTRACT(epoch FROM NOW()) * 1000
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_organizations_owner ON organizations(owner_id);
CREATE INDEX IF NOT EXISTS idx_user_organizations_user ON user_organizations(user_id);
CREATE INDEX IF NOT EXISTS idx_user_organizations_org ON user_organizations(organization_id);
CREATE INDEX IF NOT EXISTS idx_employees_org ON employees(organization_id);
CREATE INDEX IF NOT EXISTS idx_employees_user ON employees(user_id);
CREATE INDEX IF NOT EXISTS idx_employees_email ON employees(organization_id, email);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_expires ON auth_tokens(expires_at);
CREATE INDEX IF NOT EXISTS idx_invites_org ON invites(organization_id);
CREATE INDEX IF NOT EXISTS idx_invites_email ON invites(email);
CREATE INDEX IF NOT EXISTS idx_invites_status ON invites(status);
CREATE INDEX IF NOT EXISTS idx_submissions_employee ON submissions(employee_id);
CREATE INDEX IF NOT EXISTS idx_submissions_org ON submissions(organization_id);
CREATE INDEX IF NOT EXISTS idx_employee_reports_employee ON employee_reports(employee_id);
CREATE INDEX IF NOT EXISTS idx_employee_reports_org ON employee_reports(organization_id);
CREATE INDEX IF NOT EXISTS idx_company_reports_org ON company_reports(organization_id);
-- Create trigger function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = EXTRACT(epoch FROM NOW()) * 1000;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create triggers to automatically update updated_at
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_organizations_updated_at BEFORE UPDATE ON organizations
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_employees_updated_at BEFORE UPDATE ON employees
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_employee_reports_updated_at BEFORE UPDATE ON employee_reports
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_company_reports_updated_at BEFORE UPDATE ON company_reports
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,106 @@
DROP TABLE IF EXISTS company_reports; -- it's empty
DO $$
BEGIN
DO $a$
BEGIN
CREATE TYPE department_breakdown_item AS (
"department" VARCHAR,
"count" int
);
CREATE TYPE company_report_overview AS (
"total_employees" int,
"department_breakdown" department_breakdown_item[],
"submissionRate" double precision,
"last_updated" TIMESTAMP,
"average_performance_score" double precision,
"risk_level" VARCHAR
);
CREATE TYPE weaknesses AS (
"title" VARCHAR,
"description" TEXT
);
CREATE TYPE new_hire AS (
"name" VARCHAR,
"department" VARCHAR,
"role" VARCHAR,
"impact" TEXT
);
CREATE TYPE promotion AS (
"name" VARCHAR,
"from_role" VARCHAR,
"to_role" VARCHAR,
"impact" TEXT
);
CREATE TYPE departure AS (
"name" VARCHAR,
"department" VARCHAR,
"reason" TEXT,
"impact" TEXT
);
CREATE TYPE personnel_changes AS (
"new_hires" new_hire[],
"promotions" promotion[],
"departures" departure[]
);
CREATE TYPE immediate_hiring_needs AS (
"department" VARCHAR,
"role" VARCHAR,
"priority" VARCHAR,
"reasoning" TEXT
);
CREATE TYPE forward_operating_plan AS (
"title" VARCHAR,
"details" TEXT
);
CREATE TYPE organizational_impact_summary_employee AS (
"employee_name" VARCHAR,
"impact" TEXT,
"description" TEXT,
"suggested_pay" double precision
);
CREATE TYPE organizational_impact_summary AS (
"category" VARCHAR,
"employees" organizational_impact_summary_employee[]
);
CREATE TYPE team_score AS (
"employee_name" VARCHAR,
"grade" VARCHAR,
"reliability" double precision,
"role_fit" double precision,
"scalability" double precision,
"output" double precision,
"initiative" double precision
);
CREATE TYPE grading_breakdown AS (
"department_name_short" VARCHAR,
"department_name" VARCHAR,
"lead" VARCHAR,
"support" VARCHAR,
"department_grade" VARCHAR,
"executive_summary" TEXT,
"team_scores" team_score[]
);
EXCEPTION
WHEN duplicate_object THEN RAISE NOTICE 'Type department_breakdown_item or company_report_overview already exists, skipping creation.';
END
$a$ LANGUAGE plpgsql;
END
$$ LANGUAGE plpgsql;
CREATE TABLE IF NOT EXISTS company_reports (
"id" SERIAL PRIMARY KEY,
"organization_id" VARCHAR NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
"prompt_used" TEXT,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"overview" company_report_overview,
"strengths" TEXT[],
"weaknesses" weaknesses[],
"personnel_changes" personnel_changes,
"immediate_hiring_needs" immediate_hiring_needs[],
"forward_operating_plan" forward_operating_plan[],
"organizational_impact_summary" organizational_impact_summary[],
"grading_breakdown" grading_breakdown[]
);

View File

@@ -0,0 +1,131 @@
const fs = require('fs');
const path = require('path');
const { executeQuery, executeTransaction } = require('../database');
/**
* Run all database migrations
*/
async function runMigrations() {
console.log('🚀 Starting database migrations...');
try {
// Create migrations table to track what's been run
await executeQuery(`
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
executed_at BIGINT NOT NULL DEFAULT EXTRACT(epoch FROM NOW()) * 1000
);
`);
// Get list of executed migrations
const executedMigrations = await executeQuery(
'SELECT filename FROM migrations ORDER BY executed_at'
);
const executedFiles = new Set(executedMigrations.map(m => m.filename));
// Read migration files
const migrationsDir = path.join(__dirname);
const migrationFiles = fs.readdirSync(migrationsDir)
.filter(file => file.endsWith('.sql'))
.sort();
console.log(`📁 Found ${migrationFiles.length} migration files`);
for (const file of migrationFiles) {
if (executedFiles.has(file)) {
console.log(`⏭️ Skipping ${file} (already executed)`);
continue;
}
console.log(`🔄 Executing ${file}...`);
const filePath = path.join(migrationsDir, file);
const sql = fs.readFileSync(filePath, 'utf8');
await executeTransaction(async (client) => {
// Execute the migration SQL
if (process.env.USE_NEON_SERVERLESS === 'true') {
// For Neon serverless, split by statements and execute individually
const statements = sql
.split(';')
.map(s => s.trim())
.filter(s => s.length > 0);
for (const statement of statements) {
if (statement.trim()) {
await client(statement);
}
}
} else {
await client.query(sql);
}
// Record that this migration was executed
if (process.env.USE_NEON_SERVERLESS === 'true') {
await client('INSERT INTO migrations (filename) VALUES ($1)', [file]);
} else {
await client.query('INSERT INTO migrations (filename) VALUES ($1)', [file]);
}
});
console.log(`✅ Completed ${file}`);
}
console.log('🎉 All migrations completed successfully!');
} catch (error) {
console.error('❌ Migration failed:', error);
throw error;
}
}
/**
* Create a new migration file
* @param {string} name - Migration name
*/
function createMigration(name) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0];
const filename = `${timestamp}_${name}.sql`;
const filepath = path.join(__dirname, filename);
const template = `-- Migration: ${filename}
-- Description: ${name}
-- Add your SQL here
`;
fs.writeFileSync(filepath, template);
console.log(`📝 Created migration: ${filename}`);
return filename;
}
// Run migrations if this file is executed directly
if (require.main === module) {
const command = process.argv[2];
if (command === 'create') {
const name = process.argv[3];
if (!name) {
console.error('❌ Please provide a migration name: npm run db:migrate create migration_name');
process.exit(1);
}
createMigration(name);
} else {
runMigrations()
.then(() => {
console.log('🏁 Migration process completed');
process.exit(0);
})
.catch(error => {
console.error('💥 Migration process failed:', error);
process.exit(1);
});
}
}
module.exports = {
runMigrations,
createMigration
};

View File

@@ -1,25 +1,29 @@
{
"name": "functions",
"description": "Cloud Functions for Firebase",
"scripts": {
"serve": "firebase emulators:start --only functions",
"shell": "firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"engines": {
"node": "22"
},
"main": "index.js",
"dependencies": {
"@google-cloud/vertexai": "^1.10.0",
"firebase-admin": "^12.7.0",
"firebase-functions": "^6.4.0",
"stripe": "^18.5.0"
},
"devDependencies": {
"firebase-functions-test": "^3.4.1"
},
"private": true
}
"name": "functions",
"description": "NeonDB Backend Functions",
"private": true,
"main": "index.js",
"engines": {
"node": "22"
},
"scripts": {
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log",
"serve": "firebase emulators:start --only functions",
"shell": "firebase functions:shell",
"start": "npm run shell",
"db:migrate": "node migrations/migrate.js",
"db:seed": "node migrations/seed.js"
},
"dependencies": {
"@google-cloud/vertexai": "^1.10.0",
"@neondatabase/serverless": "^0.9.5",
"pg": "^8.12.0",
"stripe": "^18.5.0",
"firebase-functions": "^6.4.0"
},
"devDependencies": {
"@types/pg": "^8.11.6",
"firebase-functions-test": "^3.4.1"
}
}