a fun bot for the hc slack

feat: remove time tracking code and implement new db

dunkirk.sh e28c181f 7c3392c4

verified
+88 -78
bun.lock
···
"@types/react-dom": "^19.1.2",
"bottleneck": "^2.19.5",
"colors": "^1.4.0",
-
"drizzle-kit": "^0.30.6",
-
"drizzle-orm": "^0.41.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"slack-edge": "^1.3.7",
···
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
···
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
-
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
-
"@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
-
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="],
-
"@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="],
-
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="],
-
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="],
-
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="],
-
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="],
-
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="],
-
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="],
-
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="],
-
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="],
-
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="],
-
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="],
-
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="],
-
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="],
-
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="],
-
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="],
-
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="],
-
-
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="],
-
-
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="],
-
-
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="],
-
-
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
-
-
"@libsql/client": ["@libsql/client@0.15.2", "", { "dependencies": { "@libsql/core": "^0.15.2", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.5.4", "promise-limit": "^2.7.0" } }, "sha512-D0No4jqDj5I+buvEyFajBugohzJXCBt9aRHCEXGrJS/9obnAO2z18Os3xgyPsWX0Yw4NQfSYaayRdowqkssmXA=="],
-
-
"@libsql/core": ["@libsql/core@0.15.2", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-+UIN0OlzWa54MqnHbtaJ3FEJj6k2VrwrjX1sSSxzYlM+dWuadjMwOVp7gHpSYJGKWw0RQWLGge4fbW4TCvIm3A=="],
-
-
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4PnRdklaQg27vAZxtQgKl+xBHimCH2KRgKId+h63gkAtz5yFTMmX+Q4Ez804T1BgrZuB5ujIvueEEuust2ceSQ=="],
-
-
"@libsql/darwin-x64": ["@libsql/darwin-x64@0.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-r+Z3UXQWxluXKA5cPj5KciNsmSXVTnq9/tmDczngJrogyXwdbbSShYkzov5M+YBlUCKv2VCbNnfxxoIqQnV9Gg=="],
-
-
"@libsql/hrana-client": ["@libsql/hrana-client@0.7.0", "", { "dependencies": { "@libsql/isomorphic-fetch": "^0.3.1", "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5", "node-fetch": "^3.3.2" } }, "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw=="],
-
"@libsql/isomorphic-fetch": ["@libsql/isomorphic-fetch@0.3.1", "", {}, "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw=="],
-
"@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="],
-
-
"@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-QmGXa3TGM6URe7vCOqdvr4Koay+4h5D6y4gdhnPCvXNYrRHgpq5OwEafP9GFalbO32Y1ppLY4enO2LwY0k63Qw=="],
-
-
"@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-cx4/7/xUjgNbiRsghRHujSvIqaTNFQC7Oo1gkGXGsh8hBwkdXr1QdOpeitq745sl6OlbInRrW2C7B2juxX3hcQ=="],
-
"@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-oPrE9Zyqd7fElS9uCGW2jn55cautD+gDIflfyF5+W/QYzll5OJ2vyMBZOBgdNopuZHrmHYihbespJn3t0WJDJg=="],
-
"@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-XzyVdVe43MexkAaHzUvsi4tpPhfSDn3UndIYFrIu0lYkkiz4oKjTK7Iq96j2bcOeJv0pBGxiv+8Z9I6yp/aI2A=="],
-
"@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-xWQyAQEsX+odBrMSXTpm3WOFeoJIX7QncCkaZcsaqdEFueOdNDIdcKAQKMoNlwtj1rCxE72RK4byw/Bflf6Jgg=="],
-
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
···
"@types/node": ["@types/node@22.13.17", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-nAJuQXoyPj04uLgu+obZcSmsfOenUg6DxPKogeUy6yNCFwWaj5sBF8/G/pNo8EtBJjAfSVgfIlugR/BCOleO+g=="],
-
"@types/pg": ["@types/pg@8.6.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w=="],
"@types/pg-pool": ["@types/pg-pool@2.0.6", "", { "dependencies": { "@types/pg": "*" } }, "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ=="],
···
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
-
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
-
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
-
"detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
-
-
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
-
"drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
-
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
-
-
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
-
-
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="],
···
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
-
"js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="],
-
-
"libsql": ["libsql@0.5.4", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.4", "@libsql/darwin-x64": "0.5.4", "@libsql/linux-arm64-gnu": "0.5.4", "@libsql/linux-arm64-musl": "0.5.4", "@libsql/linux-x64-gnu": "0.5.4", "@libsql/linux-x64-musl": "0.5.4", "@libsql/win32-x64-msvc": "0.5.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-GEFeWca4SDAQFxjHWJBE6GK52LEtSskiujbG3rqmmeTO9t4sfSBKIURNLLpKDDF7fb7jmTuuRkDAn9BZGITQNw=="],
-
"module-details-from-path": ["module-details-from-path@1.0.3", "", {}, "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
-
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
-
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
-
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-protocol": ["pg-protocol@1.8.0", "", {}, "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g=="],
-
"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=="],
-
"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=="],
-
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
···
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
-
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
-
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
-
"ws": ["ws@8.18.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w=="],
-
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="],
···
"@opentelemetry/instrumentation-http/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
"@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
"@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
···
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
}
}
···
"@types/react-dom": "^19.1.2",
"bottleneck": "^2.19.5",
"colors": "^1.4.0",
+
"drizzle-kit": "^0.31.0",
+
"drizzle-orm": "^0.42.0",
+
"pg": "^8.14.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"slack-edge": "^1.3.7",
···
},
"devDependencies": {
"@types/bun": "latest",
+
"@types/pg": "^8.11.13",
},
"peerDependencies": {
"typescript": "^5",
···
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
+
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag=="],
+
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.2", "", { "os": "android", "cpu": "arm" }, "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA=="],
+
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w=="],
+
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.2", "", { "os": "android", "cpu": "x64" }, "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg=="],
+
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA=="],
+
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA=="],
+
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w=="],
+
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ=="],
+
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.2", "", { "os": "linux", "cpu": "arm" }, "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g=="],
+
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g=="],
+
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ=="],
+
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w=="],
+
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q=="],
+
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g=="],
+
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw=="],
+
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q=="],
+
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.2", "", { "os": "linux", "cpu": "x64" }, "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg=="],
+
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.2", "", { "os": "none", "cpu": "arm64" }, "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw=="],
+
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.2", "", { "os": "none", "cpu": "x64" }, "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg=="],
+
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg=="],
+
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw=="],
+
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA=="],
+
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q=="],
+
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg=="],
+
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.2", "", { "os": "win32", "cpu": "x64" }, "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
···
"@types/node": ["@types/node@22.13.17", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-nAJuQXoyPj04uLgu+obZcSmsfOenUg6DxPKogeUy6yNCFwWaj5sBF8/G/pNo8EtBJjAfSVgfIlugR/BCOleO+g=="],
+
"@types/pg": ["@types/pg@8.11.13", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-6kXByGkvRvwXLuyaWzsebs2du6+XuAB2CuMsuzP7uaihQahshVgSmB22Pmh0vQMkQ1h5+PZU0d+Di1o+WpVWJg=="],
"@types/pg-pool": ["@types/pg-pool@2.0.6", "", { "dependencies": { "@types/pg": "*" } }, "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ=="],
···
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
+
"drizzle-kit": ["drizzle-kit@0.31.0", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.2", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-pcKVT+GbfPA+bUovPIilgVOoq+onNBo/YQBG86sf3/GFHkN6lRJPm1l7dKN0IMAk57RQoIm4GUllRrasLlcaSg=="],
+
"drizzle-orm": ["drizzle-orm@0.42.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-pS8nNJm2kBNZwrOjTHJfdKkaU+KuUQmV/vk5D57NojDq4FG+0uAYGMulXtYT///HfgsMF0hnFFvu1ezI3OwOkg=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
+
"esbuild": ["esbuild@0.25.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.2", "@esbuild/android-arm": "0.25.2", "@esbuild/android-arm64": "0.25.2", "@esbuild/android-x64": "0.25.2", "@esbuild/darwin-arm64": "0.25.2", "@esbuild/darwin-x64": "0.25.2", "@esbuild/freebsd-arm64": "0.25.2", "@esbuild/freebsd-x64": "0.25.2", "@esbuild/linux-arm": "0.25.2", "@esbuild/linux-arm64": "0.25.2", "@esbuild/linux-ia32": "0.25.2", "@esbuild/linux-loong64": "0.25.2", "@esbuild/linux-mips64el": "0.25.2", "@esbuild/linux-ppc64": "0.25.2", "@esbuild/linux-riscv64": "0.25.2", "@esbuild/linux-s390x": "0.25.2", "@esbuild/linux-x64": "0.25.2", "@esbuild/netbsd-arm64": "0.25.2", "@esbuild/netbsd-x64": "0.25.2", "@esbuild/openbsd-arm64": "0.25.2", "@esbuild/openbsd-x64": "0.25.2", "@esbuild/sunos-x64": "0.25.2", "@esbuild/win32-arm64": "0.25.2", "@esbuild/win32-ia32": "0.25.2", "@esbuild/win32-x64": "0.25.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="],
···
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
"module-details-from-path": ["module-details-from-path@1.0.3", "", {}, "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
"obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="],
+
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
+
"pg": ["pg@8.14.1", "", { "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.8.0", "pg-protocol": "^1.8.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw=="],
+
+
"pg-cloudflare": ["pg-cloudflare@1.1.1", "", {}, "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q=="],
+
+
"pg-connection-string": ["pg-connection-string@2.7.0", "", {}, "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA=="],
"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.8.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw=="],
+
"pg-protocol": ["pg-protocol@1.8.0", "", {}, "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g=="],
+
"pg-types": ["pg-types@4.0.2", "", { "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-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="],
+
+
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
+
"postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
+
"postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="],
+
"postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="],
+
"postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="],
+
"postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
···
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
+
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
+
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="],
···
"@opentelemetry/instrumentation-http/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
+
"@opentelemetry/instrumentation-pg/@types/pg": ["@types/pg@8.6.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w=="],
+
"@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
"@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
+
"@types/pg-pool/@types/pg": ["@types/pg@8.6.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w=="],
+
+
"pg/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=="],
+
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
···
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
+
+
"@opentelemetry/instrumentation-pg/@types/pg/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=="],
+
+
"@types/pg-pool/@types/pg/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=="],
+
+
"pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
+
+
"pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
+
+
"pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
+
+
"pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
+
+
"@opentelemetry/instrumentation-pg/@types/pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
+
+
"@opentelemetry/instrumentation-pg/@types/pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
+
+
"@opentelemetry/instrumentation-pg/@types/pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
+
+
"@opentelemetry/instrumentation-pg/@types/pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
+
+
"@types/pg-pool/@types/pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
+
+
"@types/pg-pool/@types/pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
+
+
"@types/pg-pool/@types/pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
+
+
"@types/pg-pool/@types/pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
}
}
+16 -7
drizzle.config.ts
···
import type { Config } from "drizzle-kit";
export default {
-
schema: "./src/libs/schema.ts",
-
out: "./migrations",
-
dialect: "sqlite",
-
dbCredentials: {
-
url: "./local.db",
-
},
-
} satisfies Config;
···
import type { Config } from "drizzle-kit";
+
// Parse connection string from environment variable
+
const databaseUrl = process.env.DATABASE_URL || "";
+
const url = new URL(databaseUrl);
+
export default {
+
schema: "./src/libs/schema.ts",
+
out: "./migrations",
+
dialect: "postgresql",
+
dbCredentials: {
+
host: url.hostname,
+
port: Number.parseInt(url.port),
+
user: url.username,
+
password: url.password,
+
database: url.pathname.slice(1),
+
ssl: url.searchParams.get("sslmode") === "require",
+
},
+
} satisfies Config;
+5 -3
package.json
···
"db:push": "drizzle-kit push"
},
"devDependencies": {
-
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
···
"@types/react-dom": "^19.1.2",
"bottleneck": "^2.19.5",
"colors": "^1.4.0",
-
"drizzle-kit": "^0.30.6",
-
"drizzle-orm": "^0.41.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"slack-edge": "^1.3.7",
···
"db:push": "drizzle-kit push"
},
"devDependencies": {
+
"@types/bun": "latest",
+
"@types/pg": "^8.11.13"
},
"peerDependencies": {
"typescript": "^5"
···
"@types/react-dom": "^19.1.2",
"bottleneck": "^2.19.5",
"colors": "^1.4.0",
+
"drizzle-kit": "^0.31.0",
+
"drizzle-orm": "^0.42.0",
+
"pg": "^8.14.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"slack-edge": "^1.3.7",
+2 -4
src/features/api/index.ts
···
-
import { recentTakes, takesPerUser } from "./routes/recentTakes";
import video from "./routes/video";
import { handleApiError } from "../../libs/apiError";
···
case "video":
return await video(url);
case "recentTakes":
-
return await recentTakes();
-
case "takesPerUser":
-
return await takesPerUser(url.pathname.split("/")[3] as string);
default:
return new Response(
JSON.stringify({ error: "Route not found" }),
···
+
import { recentTakes } from "./routes/recentTakes";
import video from "./routes/video";
import { handleApiError } from "../../libs/apiError";
···
case "video":
return await video(url);
case "recentTakes":
+
return await recentTakes(url);
default:
return new Response(
JSON.stringify({ error: "Route not found" }),
+26 -87
src/features/api/routes/recentTakes.ts
···
import { takes as takesTable } from "../../../libs/schema";
import { handleApiError } from "../../../libs/apiError";
-
export async function recentTakes(): Promise<Response> {
try {
-
const recentTakes = await db
.select()
.from(takesTable)
-
.where(
-
or(
-
eq(takesTable.status, "approved"),
-
eq(takesTable.status, "uploaded"),
-
),
-
)
-
.orderBy(desc(takesTable.completedAt))
.limit(40);
if (recentTakes.length === 0) {
return new Response(
···
);
}
-
const takes = recentTakes.map((take) => ({
-
id: take.id,
-
userId: take.userId,
-
description: take.description,
-
completedAt: take.completedAt,
-
status: take.status,
-
mp4Url: take.takeUrl,
-
elapsedTime: take.elapsedTimeMs,
-
}));
return new Response(
JSON.stringify({
···
return handleApiError(error, "recentTakes");
}
}
-
-
export async function takesPerUser(userId: string): Promise<Response> {
-
try {
-
const rawTakes = await db
-
.select()
-
.from(takesTable)
-
.where(and(eq(takesTable.userId, userId)))
-
.orderBy(desc(takesTable.completedAt));
-
-
const takes = rawTakes.map((take) => ({
-
id: take.id,
-
description: take.description,
-
completedAt: take.completedAt,
-
status: take.status,
-
mp4Url: take.takeUrl,
-
elapsedTime: take.elapsedTimeMs,
-
}));
-
-
const approvedTakes = rawTakes.reduce((acc, take) => {
-
if (take.status !== "approved") return acc;
-
const multiplier = Number.parseFloat(take.multiplier || "1.0");
-
return Number(
-
(
-
acc +
-
(take.elapsedTimeMs * multiplier) / (1000 * 60 * 60)
-
).toFixed(1),
-
);
-
}, 0);
-
-
const waitingTakes = rawTakes.reduce((acc, take) => {
-
if (take.status !== "waitingUpload" && take.status !== "uploaded")
-
return acc;
-
const multiplier = Number.parseFloat(take.multiplier || "1.0");
-
return Number(
-
(
-
acc +
-
(take.elapsedTimeMs * multiplier) / (1000 * 60 * 60)
-
).toFixed(1),
-
);
-
}, 0);
-
-
const rejectedTakes = rawTakes.reduce((acc, take) => {
-
if (take.status !== "rejected") return acc;
-
const multiplier = Number.parseFloat(take.multiplier || "1.0");
-
return Number(
-
(
-
acc +
-
(take.elapsedTimeMs * multiplier) / (1000 * 60 * 60)
-
).toFixed(1),
-
);
-
}, 0);
-
-
return new Response(
-
JSON.stringify({
-
approvedTakes,
-
waitingTakes,
-
rejectedTakes,
-
takes,
-
}),
-
{
-
headers: {
-
"Content-Type": "application/json",
-
},
-
},
-
);
-
} catch (error) {
-
return handleApiError(error, "takesPerUser");
-
}
-
}
···
import { takes as takesTable } from "../../../libs/schema";
import { handleApiError } from "../../../libs/apiError";
+
export type RecentTake = {
+
id: string;
+
userId: string;
+
notes: string;
+
createdAt: Date;
+
mediaUrls: string[];
+
elapsedTimeMs: number;
+
};
+
+
export async function recentTakes(url: URL): Promise<Response> {
try {
+
const userId = url.searchParams.get("user");
+
+
const query = db
.select()
.from(takesTable)
+
.orderBy(desc(takesTable.createdAt))
+
.where(eq(takesTable.userId, userId ? userId : takesTable.userId))
.limit(40);
+
+
const recentTakes = await query;
if (recentTakes.length === 0) {
return new Response(
···
);
}
+
const takes: RecentTake[] =
+
recentTakes.map((take) => ({
+
id: take.id,
+
userId: take.userId,
+
notes: take.notes,
+
createdAt: new Date(take.createdAt),
+
mediaUrls: take.media ? JSON.parse(take.media) : [],
+
elapsedTimeMs: take.elapsedTimeMs,
+
})) || [];
return new Response(
JSON.stringify({
···
return handleApiError(error, "recentTakes");
}
}
+48 -69
src/features/api/routes/video.ts
···
import { handleApiError } from "../../../libs/apiError";
-
import { db } from "../../../libs/db";
-
import { takes as takesTable } from "../../../libs/schema";
-
import { eq, and } from "drizzle-orm";
export default async function getVideo(url: URL): Promise<Response> {
try {
-
const path = url.pathname.split("/").filter(Boolean);
-
const videoId = path[2];
-
const thumbnail = path[3] === "thumbnail";
-
-
if (!videoId) {
-
return new Response(JSON.stringify({ error: "Invalid video id" }), {
-
status: 400,
-
headers: { "Content-Type": "application/json" },
-
});
-
}
-
-
const video = await db
-
.select()
-
.from(takesTable)
-
.where(eq(takesTable.id, videoId));
-
if (video.length === 0) {
-
return new Response(JSON.stringify({ error: "Video not found" }), {
-
status: 404,
-
headers: { "Content-Type": "application/json" },
-
});
-
}
-
-
const videoData = video[0];
-
-
if (thumbnail) {
-
return Response.redirect(
-
`https://cachet.dunkirk.sh/users/${videoData?.userId}/r`,
);
}
return new Response(
`<!DOCTYPE html>
-
<html>
-
<head>
-
<title>Video Player</title>
-
<style>
-
body, html {
-
margin: 0;
-
padding: 0;
-
height: 100vh;
-
overflow: hidden;
-
}
-
.video-container {
-
position: fixed;
-
top: 0;
-
left: 0;
-
width: 100vw;
-
height: 100vh;
-
display: flex;
-
flex-direction: column;
-
justify-content: center;
-
background: linear-gradient(180deg, #000000 25%, #ffffff 50%, #000000 75%);
-
}
-
video {
-
width: 100vw;
-
height: 100vh;
-
object-fit: contain;
-
position: absolute;
-
bottom: 0;
-
}
-
</style>
-
</head>
-
<body>
-
<div class="video-container">
-
<video autoplay controls>
-
<source src="${videoData?.takeUrl}" type="video/mp4">
-
Your browser does not support the video tag.
-
</video>
-
</div>
-
</body>
-
</html>`,
{
headers: {
"Content-Type": "text/html",
···
import { handleApiError } from "../../../libs/apiError";
export default async function getVideo(url: URL): Promise<Response> {
try {
+
const params = new URLSearchParams(url.search);
+
const mediaSource = params.get("media");
+
if (!mediaSource) {
+
return new Response(
+
JSON.stringify({ error: "No media source provided" }),
+
{
+
status: 400,
+
headers: { "Content-Type": "application/json" },
+
},
);
}
return new Response(
`<!DOCTYPE html>
+
<html>
+
<head>
+
<title>Video Player</title>
+
<style>
+
body, html {
+
margin: 0;
+
padding: 0;
+
height: 100vh;
+
overflow: hidden;
+
}
+
.video-container {
+
position: fixed;
+
top: 0;
+
left: 0;
+
width: 100vw;
+
height: 100vh;
+
display: flex;
+
flex-direction: column;
+
justify-content: center;
+
background: linear-gradient(180deg, #000000 25%, #ffffff 50%, #000000 75%);
+
}
+
video {
+
width: 100vw;
+
height: 100vh;
+
object-fit: contain;
+
position: absolute;
+
bottom: 0;
+
}
+
</style>
+
</head>
+
<body>
+
<div class="video-container">
+
<video autoplay controls>
+
<source src="${mediaSource}" type="video/mp4">
+
Your browser does not support the video tag.
+
</video>
+
</div>
+
</body>
+
</html>`,
{
headers: {
"Content-Type": "text/html",
+40 -36
src/features/frontend/app.tsx
···
import { useEffect, useState } from "react";
import { prettyPrintTime } from "../../libs/time";
import { fetchUserData } from "../../libs/cachet";
export function App() {
-
const [takes, setTakes] = useState<
-
{
-
id: string;
-
userId: string;
-
description: string;
-
completedAt: Date;
-
status: string;
-
mp4Url: string;
-
elapsedTime: number;
-
}[]
-
>([]);
const [userData, setUserData] = useState<{
[key: string]: { displayName: string; imageUrl: string };
···
async function getTakes() {
const res = await fetch("/api/recentTakes");
const data = await res.json();
setTakes(data.takes);
}
getTakes();
···
{takes.map((take) => (
<div key={take.id} className="take-card">
<div className="take-header">
-
<h2 className="take-title">{take.description}</h2>
<div className="user-pill">
<div className="user-info">
<img
···
take.userId}
</span>
</div>
-
<span
-
className={`status-badge status-${take.status}`}
-
>
-
{take.status}
-
</span>
</div>
</div>
···
<div className="meta-item">
<span className="meta-label">Completed:</span>
<span className="meta-value">
-
{new Date(
-
take.completedAt,
-
).toLocaleString()}
</span>
</div>
<div className="meta-item">
<span className="meta-label">Duration:</span>
<span className="meta-value">
-
{prettyPrintTime(take.elapsedTime)}
</span>
</div>
</div>
-
{take.mp4Url && (
-
<div className="video-container">
-
<video controls className="take-video">
-
<source
-
src={take.mp4Url}
-
type="video/mp4"
-
/>
-
<track
-
kind="captions"
-
src=""
-
label="Captions"
-
/>
-
</video>
-
</div>
-
)}
</div>
))}
</div>
···
import { useEffect, useState } from "react";
import { prettyPrintTime } from "../../libs/time";
import { fetchUserData } from "../../libs/cachet";
+
import type { RecentTake } from "../api/routes/recentTakes";
export function App() {
+
const [takes, setTakes] = useState<RecentTake[]>([]);
const [userData, setUserData] = useState<{
[key: string]: { displayName: string; imageUrl: string };
···
async function getTakes() {
const res = await fetch("/api/recentTakes");
const data = await res.json();
+
+
console.log(data);
setTakes(data.takes);
}
getTakes();
···
{takes.map((take) => (
<div key={take.id} className="take-card">
<div className="take-header">
+
<h2 className="take-title">{take.notes}</h2>
<div className="user-pill">
<div className="user-info">
<img
···
take.userId}
</span>
</div>
</div>
</div>
···
<div className="meta-item">
<span className="meta-label">Completed:</span>
<span className="meta-value">
+
{new Date(take.createdAt).toLocaleString()}
</span>
</div>
<div className="meta-item">
<span className="meta-label">Duration:</span>
<span className="meta-value">
+
{prettyPrintTime(take.elapsedTimeMs)}
</span>
</div>
</div>
+
{take.mediaUrls?.map((url: string, index: number) => {
+
const isVideo = url.endsWith(".mp4");
+
return (
+
<div
+
key={`media-${take.id}-${index}`}
+
className={
+
isVideo
+
? "video-container"
+
: "image-container"
+
}
+
>
+
{isVideo ? (
+
<video controls className="take-video">
+
<source
+
src={url}
+
type="video/mp4"
+
/>
+
<track
+
kind="captions"
+
src=""
+
label="Captions"
+
/>
+
</video>
+
) : (
+
<img
+
src={url}
+
alt=""
+
className="take-image"
+
/>
+
)}
+
</div>
+
);
+
})}
</div>
))}
</div>
+5 -6
src/features/takes/handlers/help.ts
···
-
import TakesConfig from "../../../libs/config";
import type { MessageResponse } from "../types";
export default async function handleHelp(): Promise<MessageResponse> {
return {
-
text: `*Takes Commands*\n\n• \`/takes start [description]\` - Start a new takes session, optionally specifying description\n• \`/takes pause\` - Pause your current session (max ${TakesConfig.MAX_PAUSE_DURATION} min)\n• \`/takes resume\` - Resume your paused session\n• \`/takes stop [notes]\` - End your current session with optional notes\n• \`/takes status\` - Check the status of your session\n• \`/takes history\` - View your past takes sessions`,
response_type: "ephemeral",
blocks: [
{
···
type: "section",
text: {
type: "mrkdwn",
-
text: `• \`/takes start [minutes]\` - Start a new session (default: ${TakesConfig.DEFAULT_SESSION_LENGTH} min)\n• \`/takes pause\` - Pause your session (max ${TakesConfig.MAX_PAUSE_DURATION} min)\n• \`/takes resume\` - Resume your paused session\n• \`/takes stop [notes]\` - End session with optional notes\n• \`/takes status\` - Check status\n• \`/takes history\` - View past sessions`,
},
},
{
···
type: "button",
text: {
type: "plain_text",
-
text: "🎬 Start New Session",
emoji: true,
},
-
value: "start",
-
action_id: "takes_start",
},
{
type: "button",
···
import type { MessageResponse } from "../types";
export default async function handleHelp(): Promise<MessageResponse> {
return {
+
text: "*Takes Commands*\n\n• `/takes history` - View your past takes sessions",
response_type: "ephemeral",
blocks: [
{
···
type: "section",
text: {
type: "mrkdwn",
+
text: "• `/takes history` - View your past takes sessions",
},
},
{
···
type: "button",
text: {
type: "plain_text",
+
text: "🏡 Home",
emoji: true,
},
+
value: "status",
+
action_id: "takes_home",
},
{
type: "button",
+21 -38
src/features/takes/handlers/history.ts
···
import type { AnyMessageBlock } from "slack-edge";
-
import TakesConfig from "../../../libs/config";
-
import { getCompletedTakes } from "../services/database";
import type { MessageResponse } from "../types";
-
import { calculateElapsedTime } from "../../../libs/time-periods";
import { prettyPrintTime } from "../../../libs/time";
export async function handleHistory(userId: string): Promise<MessageResponse> {
-
// Get completed takes for the user
-
const completedTakes = (
-
await getCompletedTakes(userId, TakesConfig.MAX_HISTORY_ITEMS)
-
).sort(
-
(a, b) =>
-
(b.completedAt?.getTime() ?? 0) - (a.completedAt?.getTime() ?? 0),
-
);
-
if (completedTakes.length === 0) {
-
return {
-
text: "You haven't completed any takes sessions yet.",
-
response_type: "ephemeral",
-
};
-
}
// Create blocks for each completed take
const historyBlocks: AnyMessageBlock[] = [
···
type: "header",
text: {
type: "plain_text",
-
text: `📋 Your most recent ${completedTakes.length} Takes sessions`,
emoji: true,
},
},
];
-
for (const take of completedTakes) {
-
const elapsedTime = calculateElapsedTime(JSON.parse(take.periods));
-
const notes = take.notes ? `\n• Notes: ${take.notes}` : "";
-
const description = take.description
-
? `\n• Description: ${take.description}\n`
-
: "";
historyBlocks.push({
type: "section",
text: {
type: "mrkdwn",
-
text: `*Duration:* \`${prettyPrintTime(elapsedTime)}\`\n*Status:* ${take.status}\n${notes ? `*Notes:* ${take.notes}\n` : ""}${description ? `*Description:* ${take.description}\n` : ""}`,
},
});
// Add a divider between entries
-
if (take !== completedTakes[completedTakes.length - 1]) {
historyBlocks.push({
type: "divider",
});
···
type: "button",
text: {
type: "plain_text",
-
text: "🎬 Start New Session",
-
emoji: true,
-
},
-
value: "start",
-
action_id: "takes_start",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "👁️ Status",
emoji: true,
},
value: "status",
-
action_id: "takes_status",
},
{
type: "button",
···
});
return {
-
text: `Your recent takes history (${completedTakes.length} sessions)`,
response_type: "ephemeral",
blocks: historyBlocks,
};
···
import type { AnyMessageBlock } from "slack-edge";
import type { MessageResponse } from "../types";
import { prettyPrintTime } from "../../../libs/time";
+
import { db } from "../../../libs/db";
+
import { takes as takesTable } from "../../../libs/schema";
+
import { eq, and, desc } from "drizzle-orm";
export async function handleHistory(userId: string): Promise<MessageResponse> {
+
const takes = await db
+
.select()
+
.from(takesTable)
+
.where(and(eq(takesTable.userId, userId)))
+
.orderBy(desc(takesTable.createdAt));
+
const takeTimeMs = takes.reduce(
+
(acc, take) => acc + take.elapsedTimeMs * Number(take.multiplier),
+
0,
+
);
+
const takeTime = prettyPrintTime(takeTimeMs);
// Create blocks for each completed take
const historyBlocks: AnyMessageBlock[] = [
···
type: "header",
text: {
type: "plain_text",
+
text: `📋 you have uploaded ${takes.length} notes for a total of ${takeTime}`,
emoji: true,
},
},
];
+
for (const take of takes) {
const notes = take.notes ? `\n• Notes: ${take.notes}` : "";
+
const duration = prettyPrintTime(take.elapsedTimeMs);
historyBlocks.push({
type: "section",
text: {
type: "mrkdwn",
+
text: `*Duration:* \`${duration}\`\n${notes ? `*Notes:* ${take.notes}\n` : ""}${take.multiplier !== "1.0" ? `\n*Multiplier:* ${take.multiplier}\n` : ""}`,
},
});
// Add a divider between entries
+
if (take !== takes[takes.length - 1]) {
historyBlocks.push({
type: "divider",
});
···
type: "button",
text: {
type: "plain_text",
+
text: "🏡 Home",
emoji: true,
},
value: "status",
+
action_id: "takes_home",
},
{
type: "button",
···
});
return {
+
text: `${takes.length} notes for a total of ${takeTime}`,
response_type: "ephemeral",
blocks: historyBlocks,
};
+12 -29
src/features/takes/handlers/home.ts
···
.where(and(eq(takesTable.userId, userId)))
.orderBy(desc(takesTable.createdAt));
-
const approvedTakes = takes.reduce((acc, take) => {
-
if (take.status !== "approved") return acc;
-
const multiplier = Number.parseFloat(take.multiplier || "1.0");
-
const hoursElapsed =
-
(take.elapsedTimeMs * multiplier) / (1000 * 60 * 60);
-
return Number((acc + hoursElapsed).toFixed(1));
-
}, 0);
-
-
const waitingTakesStats = takes.reduce(
-
(acc: { count: number; hours: number }, take) => {
-
if (take.status !== "waitingUpload" && take.status !== "uploaded")
-
return acc;
-
const multiplier = Number.parseFloat(take.multiplier || "1.0");
-
const hoursElapsed =
-
(take.elapsedTimeMs * multiplier) / (1000 * 60 * 60);
-
return {
-
count: acc.count + 1,
-
hours: Number((acc.hours + hoursElapsed).toFixed(1)),
-
};
-
},
-
{ count: 0, hours: 0 },
);
return {
-
text: `You have logged \`${approvedTakes}\` approved takes!`,
response_type: "ephemeral",
blocks: [
{
···
type: "section",
text: {
type: "mrkdwn",
-
text: `You have logged \`${approvedTakes}\` takes! \n\n*Pending Approval:* \`${waitingTakesStats.count}\` sessions, \`${waitingTakesStats.hours}\` hours total`,
},
},
{
···
type: "button",
text: {
type: "plain_text",
-
text: "🎬 Start New Session",
emoji: true,
},
-
value: "start",
-
action_id: "takes_start",
},
{
type: "button",
text: {
type: "plain_text",
-
text: "📋 History",
emoji: true,
},
-
value: "history",
-
action_id: "takes_history",
},
],
},
···
.where(and(eq(takesTable.userId, userId)))
.orderBy(desc(takesTable.createdAt));
+
const takeTimeMs = takes.reduce(
+
(acc, take) => acc + take.elapsedTimeMs * Number(take.multiplier),
+
0,
);
+
const takeTime = prettyPrintTime(takeTimeMs);
return {
+
text: `You have logged ${takeTime} of takes!`,
response_type: "ephemeral",
blocks: [
{
···
type: "section",
text: {
type: "mrkdwn",
+
text: `You have logged ${takeTime} of takes!`,
},
},
{
···
type: "button",
text: {
type: "plain_text",
+
text: "📋 History",
emoji: true,
},
+
value: "history",
+
action_id: "takes_history",
},
{
type: "button",
text: {
type: "plain_text",
+
text: "🔄 Refresh",
emoji: true,
},
+
value: "status",
+
action_id: "takes_home",
},
],
},
-122
src/features/takes/handlers/pause.ts
···
-
import { db } from "../../../libs/db";
-
import { takes as takesTable } from "../../../libs/schema";
-
import { eq } from "drizzle-orm";
-
import TakesConfig from "../../../libs/config";
-
import { getActiveTake } from "../services/database";
-
import type { MessageResponse } from "../types";
-
import { generateSlackDate, prettyPrintTime } from "../../../libs/time";
-
import {
-
addNewPeriod,
-
getPausedTimeRemaining,
-
getRemainingTime,
-
} from "../../../libs/time-periods";
-
-
export default async function handlePause(
-
userId: string,
-
): Promise<MessageResponse | undefined> {
-
const activeTake = await getActiveTake(userId);
-
if (activeTake.length === 0) {
-
return {
-
text: `You don't have an active takes session! Use \`/takes start\` to begin.`,
-
response_type: "ephemeral",
-
};
-
}
-
-
const takeToUpdate = activeTake[0];
-
if (!takeToUpdate) {
-
return;
-
}
-
-
const newPeriods = JSON.stringify(
-
addNewPeriod(takeToUpdate.periods, "paused"),
-
);
-
-
const pausedTime = getPausedTimeRemaining(newPeriods);
-
const endTime = getRemainingTime(
-
takeToUpdate.targetDurationMs,
-
takeToUpdate.periods,
-
);
-
-
if (pausedTime > TakesConfig.MAX_PAUSE_DURATION * 60000) {
-
return {
-
text: `You can't pause for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes!`,
-
response_type: "ephemeral",
-
};
-
}
-
-
// Update the takes entry to paused status
-
await db
-
.update(takesTable)
-
.set({
-
status: "paused",
-
periods: newPeriods,
-
notifiedPauseExpiration: false, // Reset pause expiration notification
-
})
-
.where(eq(takesTable.id, takeToUpdate.id));
-
-
const descriptionText = takeToUpdate.description
-
? `\n\n*Working on:* ${takeToUpdate.description}`
-
: "";
-
-
return {
-
text: `⏸️ Session paused! You have ${prettyPrintTime(endTime.remaining)} remaining. It will automatically finish at ${generateSlackDate(new Date(Date.now() + TakesConfig.MAX_PAUSE_DURATION * 60000))}`,
-
response_type: "ephemeral",
-
blocks: [
-
{
-
type: "section",
-
text: {
-
type: "mrkdwn",
-
text: `⏸️ Session paused! You have ${prettyPrintTime(endTime.remaining)} remaining.${descriptionText}`,
-
},
-
},
-
{
-
type: "divider",
-
},
-
{
-
type: "context",
-
elements: [
-
{
-
type: "mrkdwn",
-
text: `It will automatically finish in ${prettyPrintTime(pausedTime)} (by ${generateSlackDate(new Date(new Date().getTime() - pausedTime))}) if not resumed.`,
-
},
-
],
-
},
-
{
-
type: "actions",
-
elements: [
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "▶️ Resume",
-
emoji: true,
-
},
-
value: "resume",
-
action_id: "takes_resume",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "⏹️ Stop",
-
emoji: true,
-
},
-
value: "stop",
-
action_id: "takes_stop",
-
style: "danger",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "🔄 Refresh",
-
emoji: true,
-
},
-
value: "status",
-
action_id: "takes_status",
-
},
-
],
-
},
-
],
-
};
-
}
···
-121
src/features/takes/handlers/resume.ts
···
-
import { db } from "../../../libs/db";
-
import { takes as takesTable } from "../../../libs/schema";
-
import { eq } from "drizzle-orm";
-
import { generateSlackDate, prettyPrintTime } from "../../../libs/time";
-
import { getPausedTake } from "../services/database";
-
import type { MessageResponse } from "../types";
-
import { addNewPeriod, getRemainingTime } from "../../../libs/time-periods";
-
-
export default async function handleResume(
-
userId: string,
-
): Promise<MessageResponse | undefined> {
-
const pausedTake = await getPausedTake(userId);
-
if (pausedTake.length === 0) {
-
return {
-
text: `You don't have a paused takes session!`,
-
response_type: "ephemeral",
-
};
-
}
-
-
const pausedSession = pausedTake[0];
-
if (!pausedSession) {
-
return;
-
}
-
-
const now = new Date();
-
const newPeriods = JSON.stringify(
-
addNewPeriod(pausedSession.periods, "active"),
-
);
-
-
// Update the takes entry to active status
-
await db
-
.update(takesTable)
-
.set({
-
status: "active",
-
lastResumeAt: now,
-
periods: newPeriods,
-
notifiedLowTime: false, // Reset low time notification
-
})
-
.where(eq(takesTable.id, pausedSession.id));
-
-
const endTime = getRemainingTime(
-
pausedSession.targetDurationMs,
-
pausedSession.periods,
-
);
-
-
const descriptionText = pausedSession.description
-
? `\n\n*Working on:* ${pausedSession.description}`
-
: "";
-
-
return {
-
text: `▶️ Takes session resumed! You have ${prettyPrintTime(endTime.remaining)} remaining in your session.`,
-
response_type: "ephemeral",
-
blocks: [
-
{
-
type: "section",
-
text: {
-
type: "mrkdwn",
-
text: `▶️ Takes session resumed!${descriptionText}`,
-
},
-
},
-
{
-
type: "divider",
-
},
-
{
-
type: "context",
-
elements: [
-
{
-
type: "mrkdwn",
-
text: `You have ${prettyPrintTime(endTime.remaining)} remaining until ${generateSlackDate(endTime.endTime)}.`,
-
},
-
],
-
},
-
{
-
type: "actions",
-
elements: [
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "✍️ edit",
-
emoji: true,
-
},
-
value: "edit",
-
action_id: "takes_edit",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "⏸️ Pause",
-
emoji: true,
-
},
-
value: "pause",
-
action_id: "takes_pause",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "⏹️ Stop",
-
emoji: true,
-
},
-
value: "stop",
-
action_id: "takes_stop",
-
style: "danger",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "🔄 Refresh",
-
emoji: true,
-
},
-
value: "status",
-
action_id: "takes_status",
-
},
-
],
-
},
-
],
-
};
-
}
···
-123
src/features/takes/handlers/start.ts
···
-
import type { MessageResponse } from "../types";
-
import { getActiveTake } from "../services/database";
-
import { db } from "../../../libs/db";
-
import { takes as takesTable } from "../../../libs/schema";
-
import TakesConfig from "../../../libs/config";
-
import { generateSlackDate, prettyPrintTime } from "../../../libs/time";
-
import { getRemainingTime } from "../../../libs/time-periods";
-
-
export default async function handleStart(
-
userId: string,
-
channelId: string,
-
description?: string,
-
): Promise<MessageResponse> {
-
const activeTake = await getActiveTake(userId);
-
if (activeTake.length > 0) {
-
return {
-
text: "You already have an active takes session! Use `/takes status` to check it.",
-
response_type: "ephemeral",
-
};
-
}
-
-
// Create new takes session
-
const newTake = {
-
id: Bun.randomUUIDv7(),
-
userId,
-
status: "active",
-
targetDurationMs: TakesConfig.DEFAULT_SESSION_LENGTH * 60000,
-
periods: JSON.stringify([
-
{
-
type: "active",
-
startTime: Date.now(),
-
endTime: null,
-
},
-
]),
-
elapsedTimeMs: 0,
-
description: description || null,
-
notifiedLowTime: false,
-
notifiedPauseExpiration: false,
-
};
-
-
await db.insert(takesTable).values(newTake);
-
-
// Calculate end time for message
-
const endTime = getRemainingTime(
-
TakesConfig.DEFAULT_SESSION_LENGTH * 60000,
-
newTake.periods,
-
);
-
-
const descriptionText = description
-
? `\n\n*Working on:* ${description}`
-
: "";
-
return {
-
text: `🎬 Takes session started! You have ${prettyPrintTime(endTime.remaining)} until ${generateSlackDate(endTime.endTime)}.${descriptionText}`,
-
response_type: "ephemeral",
-
blocks: [
-
{
-
type: "section",
-
text: {
-
type: "mrkdwn",
-
text: `🎬 Takes session started!${descriptionText}`,
-
},
-
},
-
{
-
type: "divider",
-
},
-
{
-
type: "context",
-
elements: [
-
{
-
type: "mrkdwn",
-
text: `You have ${prettyPrintTime(endTime.remaining)} left until ${generateSlackDate(endTime.endTime)}.`,
-
},
-
],
-
},
-
{
-
type: "actions",
-
elements: [
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "✍️ edit",
-
emoji: true,
-
},
-
value: "edit",
-
action_id: "takes_edit",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "⏸️ Pause",
-
emoji: true,
-
},
-
value: "pause",
-
action_id: "takes_pause",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "⏹️ Stop",
-
emoji: true,
-
},
-
value: "stop",
-
action_id: "takes_stop",
-
style: "danger",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "🔄 Refresh",
-
emoji: true,
-
},
-
value: "status",
-
action_id: "takes_status",
-
},
-
],
-
},
-
],
-
};
-
}
···
-253
src/features/takes/handlers/status.ts
···
-
import TakesConfig from "../../../libs/config";
-
import { generateSlackDate, prettyPrintTime } from "../../../libs/time";
-
import {
-
getPausedTimeRemaining,
-
getRemainingTime,
-
} from "../../../libs/time-periods";
-
import {
-
getActiveTake,
-
getCompletedTakes,
-
getPausedTake,
-
} from "../services/database";
-
import { expirePausedSessions } from "../services/notifications";
-
import type { MessageResponse } from "../types";
-
-
export default async function handleStatus(
-
userId: string,
-
): Promise<MessageResponse | undefined> {
-
const activeTake = await getActiveTake(userId);
-
-
// First, check for expired paused sessions
-
await expirePausedSessions();
-
-
if (activeTake.length > 0) {
-
const take = activeTake[0];
-
if (!take) {
-
return;
-
}
-
-
const endTime = getRemainingTime(take.targetDurationMs, take.periods);
-
-
// Add description to display if present
-
const descriptionText = take.description
-
? `\n\n*Working on:* ${take.description}`
-
: "";
-
-
return {
-
text: `🎬 You have an active takes session with ${prettyPrintTime(endTime.remaining)} remaining.${descriptionText}`,
-
response_type: "ephemeral",
-
blocks: [
-
{
-
type: "section",
-
text: {
-
type: "mrkdwn",
-
text: `🎬 You have an active takes session${descriptionText}`,
-
},
-
},
-
{
-
type: "divider",
-
},
-
{
-
type: "context",
-
elements: [
-
{
-
type: "mrkdwn",
-
text: `You have ${prettyPrintTime(endTime.remaining)} remaining until ${generateSlackDate(endTime.endTime)}.`,
-
},
-
],
-
},
-
{
-
type: "actions",
-
elements: [
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "✍️ edit",
-
emoji: true,
-
},
-
value: "edit",
-
action_id: "takes_edit",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "⏸️ Pause",
-
emoji: true,
-
},
-
value: "pause",
-
action_id: "takes_pause",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "⏹️ Stop",
-
emoji: true,
-
},
-
value: "stop",
-
action_id: "takes_stop",
-
style: "danger",
-
},
-
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "🔄 Refresh",
-
emoji: true,
-
},
-
value: "status",
-
action_id: "takes_status",
-
},
-
],
-
},
-
],
-
};
-
}
-
-
// Check for paused session
-
const pausedTakeStatus = await getPausedTake(userId);
-
-
if (pausedTakeStatus.length > 0) {
-
const pausedTake = pausedTakeStatus[0];
-
if (!pausedTake) {
-
return;
-
}
-
-
// Calculate how much time remains before auto-completion
-
const endTime = getRemainingTime(
-
pausedTake.targetDurationMs,
-
pausedTake.periods,
-
);
-
const pauseExpires = getPausedTimeRemaining(pausedTake.periods);
-
-
// Add notes to display if present
-
const descriptionText = pausedTake.description
-
? `\n\n*Working on:* ${pausedTake.description}`
-
: "";
-
-
return {
-
text: `⏸️ You have a paused takes session. It will auto-complete in ${prettyPrintTime(pauseExpires)} if not resumed.`,
-
response_type: "ephemeral",
-
blocks: [
-
{
-
type: "section",
-
text: {
-
type: "mrkdwn",
-
text: `⏸️ Session paused! You have ${prettyPrintTime(endTime.remaining)} remaining.${descriptionText}`,
-
},
-
},
-
{
-
type: "divider",
-
},
-
{
-
type: "context",
-
elements: [
-
{
-
type: "mrkdwn",
-
text: `It will automatically finish in ${prettyPrintTime(pauseExpires)} (by ${generateSlackDate(new Date(new Date().getTime() - pauseExpires))}) if not resumed.`,
-
},
-
],
-
},
-
{
-
type: "actions",
-
elements: [
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "▶️ Resume",
-
emoji: true,
-
},
-
value: "resume",
-
action_id: "takes_resume",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "⏹️ Stop",
-
emoji: true,
-
},
-
value: "stop",
-
action_id: "takes_stop",
-
style: "danger",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "🔄 Refresh",
-
emoji: true,
-
},
-
value: "status",
-
action_id: "takes_status",
-
},
-
],
-
},
-
],
-
};
-
}
-
-
// Check history of completed sessions
-
const completedSessions = await getCompletedTakes(userId);
-
const takeTime = completedSessions.length
-
? (() => {
-
const diffMs =
-
new Date().getTime() -
-
// @ts-expect-error - TS doesn't know that we are checking the length
-
completedSessions[completedSessions.length - 1]
-
?.completedAt;
-
-
const hours = Math.ceil(diffMs / (1000 * 60 * 60));
-
if (hours < 24) return `${hours} hours`;
-
-
const weeks = Math.floor(diffMs / (1000 * 60 * 60 * 24 * 7));
-
if (weeks > 0 && weeks < 4) return `${weeks} weeks`;
-
-
const months = Math.floor(diffMs / (1000 * 60 * 60 * 24 * 30));
-
return `${months} months`;
-
})()
-
: 0;
-
-
return {
-
text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the last ${takeTime}.`,
-
response_type: "ephemeral",
-
blocks: [
-
{
-
type: "section",
-
text: {
-
type: "mrkdwn",
-
text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the last ${takeTime}.`,
-
},
-
},
-
{
-
type: "actions",
-
elements: [
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "🎬 Start New Session",
-
emoji: true,
-
},
-
value: "start",
-
action_id: "takes_start",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "📋 History",
-
emoji: true,
-
},
-
value: "history",
-
action_id: "takes_history",
-
},
-
],
-
},
-
],
-
};
-
}
···
-171
src/features/takes/handlers/stop.ts
···
-
import { slackClient } from "../../../index";
-
import { db } from "../../../libs/db";
-
import { takes as takesTable } from "../../../libs/schema";
-
import { eq } from "drizzle-orm";
-
import { getActiveTake, getPausedTake } from "../services/database";
-
import type { MessageResponse } from "../types";
-
import { prettyPrintTime } from "../../../libs/time";
-
import { calculateElapsedTime } from "../../../libs/time-periods";
-
-
export default async function handleStop(
-
userId: string,
-
args?: string[],
-
): Promise<MessageResponse | undefined> {
-
const activeTake = await getActiveTake(userId);
-
-
if (activeTake.length === 0) {
-
const pausedTake = await getPausedTake(userId);
-
-
if (pausedTake.length === 0) {
-
return {
-
text: `You don't have an active or paused takes session!`,
-
response_type: "ephemeral",
-
};
-
}
-
-
// Mark the paused session as completed
-
const pausedTakeToStop = pausedTake[0];
-
if (!pausedTakeToStop) {
-
return;
-
}
-
-
// Extract notes if provided
-
let notes = undefined;
-
if (args && args.length > 1) {
-
notes = args.slice(1).join(" ");
-
}
-
-
const elapsed = calculateElapsedTime(
-
JSON.parse(pausedTakeToStop.periods),
-
);
-
-
const res = await slackClient.chat.postMessage({
-
channel: userId,
-
text: "🎬 Your paused takes session has been completed. Please upload your takes video in this thread within the next 24 hours!",
-
blocks: [
-
{
-
type: "section",
-
text: {
-
type: "mrkdwn",
-
text: "🎬 Your paused takes session has been completed. Please upload your takes video in this thread within the next 24 hours!",
-
},
-
},
-
{
-
type: "divider",
-
},
-
{
-
type: "context",
-
elements: [
-
{
-
type: "mrkdwn",
-
text: `*Elapsed Time:* \`${prettyPrintTime(elapsed)}\`${pausedTakeToStop.description ? ` working on: *${pausedTakeToStop.description}*` : ""}`,
-
},
-
],
-
},
-
],
-
});
-
-
await db
-
.update(takesTable)
-
.set({
-
status: "waitingUpload",
-
ts: res.ts,
-
completedAt: new Date(),
-
elapsedTimeMs: elapsed,
-
...(notes && { notes }),
-
})
-
.where(eq(takesTable.id, pausedTakeToStop.id));
-
} else {
-
// Mark the active session as completed
-
const activeTakeToStop = activeTake[0];
-
if (!activeTakeToStop) {
-
return;
-
}
-
-
// Extract notes if provided
-
let notes = undefined;
-
if (args && args.length > 1) {
-
notes = args.slice(1).join(" ");
-
}
-
-
const elapsed = calculateElapsedTime(
-
JSON.parse(activeTakeToStop.periods),
-
);
-
-
const res = await slackClient.chat.postMessage({
-
channel: userId,
-
text: "🎬 Your takes session has been completed. Please upload your takes video in this thread within the next 24 hours!",
-
blocks: [
-
{
-
type: "section",
-
text: {
-
type: "mrkdwn",
-
text: "🎬 Your takes session has been completed. Please upload your takes video in this thread within the next 24 hours!",
-
},
-
},
-
{
-
type: "divider",
-
},
-
{
-
type: "context",
-
elements: [
-
{
-
type: "mrkdwn",
-
text: `\`${prettyPrintTime(elapsed)}\`${activeTakeToStop.description ? ` working on: *${activeTakeToStop.description}*` : ""}`,
-
},
-
],
-
},
-
],
-
});
-
-
await db
-
.update(takesTable)
-
.set({
-
status: "waitingUpload",
-
ts: res.ts,
-
completedAt: new Date(),
-
elapsedTimeMs: elapsed,
-
...(notes && { notes }),
-
})
-
.where(eq(takesTable.id, activeTakeToStop.id));
-
}
-
-
return {
-
text: "✅ Takes session completed! I hope you had fun!",
-
response_type: "ephemeral",
-
blocks: [
-
{
-
type: "section",
-
text: {
-
type: "mrkdwn",
-
text: "✅ Takes session completed! I hope you had fun!",
-
},
-
},
-
{
-
type: "actions",
-
elements: [
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "🎬 Start New Session",
-
emoji: true,
-
},
-
value: "start",
-
action_id: "takes_start",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "📋 History",
-
emoji: true,
-
},
-
value: "history",
-
action_id: "takes_history",
-
},
-
],
-
},
-
],
-
};
-
}
···
-2
src/features/takes/index.ts
···
import setupCommands from "./setup/commands";
import setupActions from "./setup/actions";
-
import setupNotifications from "./setup/notifications";
const takes = async () => {
setupCommands();
setupActions();
-
setupNotifications();
};
export default takes;
···
import setupCommands from "./setup/commands";
import setupActions from "./setup/actions";
const takes = async () => {
setupCommands();
setupActions();
};
export default takes;
-40
src/features/takes/services/database.ts
···
-
import { db } from "../../../libs/db";
-
import { takes as takesTable } from "../../../libs/schema";
-
import { eq, and, desc, not } from "drizzle-orm";
-
-
export async function getActiveTake(userId: string) {
-
return db
-
.select()
-
.from(takesTable)
-
.where(
-
and(eq(takesTable.userId, userId), eq(takesTable.status, "active")),
-
)
-
.limit(1);
-
}
-
-
export async function getPausedTake(userId: string) {
-
return db
-
.select()
-
.from(takesTable)
-
.where(
-
and(eq(takesTable.userId, userId), eq(takesTable.status, "paused")),
-
)
-
.limit(1);
-
}
-
-
export async function getCompletedTakes(userId: string, limit = 5) {
-
return db
-
.select()
-
.from(takesTable)
-
.where(
-
and(
-
eq(takesTable.userId, userId),
-
and(
-
not(eq(takesTable.status, "active")),
-
not(eq(takesTable.status, "paused")),
-
),
-
),
-
)
-
.orderBy(desc(takesTable.completedAt))
-
.limit(limit);
-
}
···
-199
src/features/takes/services/notifications.ts
···
-
import { slackApp } from "../../../index";
-
import TakesConfig from "../../../libs/config";
-
import { db } from "../../../libs/db";
-
import { takes as takesTable } from "../../../libs/schema";
-
import { eq } from "drizzle-orm";
-
import {
-
calculateElapsedTime,
-
getPausedDuration,
-
getRemainingTime,
-
} from "../../../libs/time-periods";
-
import { prettyPrintTime } from "../../../libs/time";
-
-
// Check for paused sessions that have exceeded the max pause duration
-
export async function expirePausedSessions() {
-
const now = new Date();
-
const pausedTakes = await db
-
.select()
-
.from(takesTable)
-
.where(eq(takesTable.status, "paused"));
-
-
for (const take of pausedTakes) {
-
const pausedDuration = getPausedDuration(take.periods) / 60000; // Convert to minutes
-
-
// Send warning notification when getting close to expiration
-
if (
-
pausedDuration >
-
TakesConfig.MAX_PAUSE_DURATION -
-
TakesConfig.NOTIFICATIONS.PAUSE_EXPIRATION_WARNING &&
-
!take.notifiedPauseExpiration
-
) {
-
// Update notification flag
-
await db
-
.update(takesTable)
-
.set({
-
notifiedPauseExpiration: true,
-
})
-
.where(eq(takesTable.id, take.id));
-
-
// Send warning message
-
try {
-
const timeRemaining = Math.round(
-
TakesConfig.MAX_PAUSE_DURATION - pausedDuration,
-
);
-
await slackApp.client.chat.postMessage({
-
channel: take.userId,
-
text: `⚠️ Reminder: Your paused takes session will automatically complete in about ${timeRemaining} minutes if not resumed.`,
-
});
-
} catch (error) {
-
console.error(
-
"Failed to send pause expiration warning:",
-
error,
-
);
-
}
-
}
-
-
// Calculate elapsed time
-
const elapsedTime = calculateElapsedTime(JSON.parse(take.periods));
-
-
// Auto-expire paused sessions that exceed the max pause duration
-
if (pausedDuration > TakesConfig.MAX_PAUSE_DURATION) {
-
let ts: string | undefined;
-
// Notify user that their session was auto-completed
-
try {
-
const res = await slackApp.client.chat.postMessage({
-
channel: take.userId,
-
text: `⏰ Your paused takes session has been automatically completed because it was paused for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes.\n\nPlease upload your takes video in this thread within the next 24 hours!`,
-
blocks: [
-
{
-
type: "section",
-
text: {
-
type: "mrkdwn",
-
text: `⏰ Your paused takes session has been automatically completed because it was paused for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes.\n\nPlease upload your takes video in this thread within the next 24 hours!`,
-
},
-
},
-
{
-
type: "divider",
-
},
-
{
-
type: "context",
-
elements: [
-
{
-
type: "mrkdwn",
-
text: `\`${prettyPrintTime(elapsedTime)}\`${take.description ? ` working on: *${take.description}*` : ""}`,
-
},
-
],
-
},
-
],
-
});
-
ts = res.ts;
-
} catch (error) {
-
console.error(
-
"Failed to notify user of auto-completed session:",
-
error,
-
);
-
}
-
-
await db
-
.update(takesTable)
-
.set({
-
status: "waitingUpload",
-
completedAt: now,
-
elapsedTimeMs: elapsedTime,
-
ts,
-
notes: take.notes
-
? `${take.notes} (Automatically completed due to pause timeout)`
-
: "Automatically completed due to pause timeout",
-
})
-
.where(eq(takesTable.id, take.id));
-
}
-
}
-
}
-
-
// Check for active sessions that are almost done
-
export async function checkActiveSessions() {
-
const now = new Date();
-
const activeTakes = await db
-
.select()
-
.from(takesTable)
-
.where(eq(takesTable.status, "active"));
-
-
for (const take of activeTakes) {
-
const endTime = getRemainingTime(take.targetDurationMs, take.periods);
-
-
const remainingMinutes = endTime.remaining / 60000;
-
-
if (
-
remainingMinutes <= TakesConfig.NOTIFICATIONS.LOW_TIME_WARNING &&
-
remainingMinutes > 0 &&
-
!take.notifiedLowTime
-
) {
-
await db
-
.update(takesTable)
-
.set({ notifiedLowTime: true })
-
.where(eq(takesTable.id, take.id));
-
-
try {
-
await slackApp.client.chat.postMessage({
-
channel: take.userId,
-
text: `⏱️ Your takes session has less than ${TakesConfig.NOTIFICATIONS.LOW_TIME_WARNING} minutes remaining.`,
-
});
-
} catch (error) {
-
console.error("Failed to send low time warning:", error);
-
}
-
}
-
-
const elapsedTime = calculateElapsedTime(JSON.parse(take.periods));
-
-
if (endTime.remaining <= 0) {
-
let ts: string | undefined;
-
try {
-
const res = await slackApp.client.chat.postMessage({
-
channel: take.userId,
-
text: "⏰ Your takes session has automatically completed because the time is up. Please upload your takes video in this thread within the next 24 hours!",
-
blocks: [
-
{
-
type: "section",
-
text: {
-
type: "mrkdwn",
-
text: "⏰ Your takes session has automatically completed because the time is up. Please upload your takes video in this thread within the next 24 hours!",
-
},
-
},
-
{
-
type: "divider",
-
},
-
{
-
type: "context",
-
elements: [
-
{
-
type: "mrkdwn",
-
text: `\`${prettyPrintTime(elapsedTime)}\`${take.description ? ` working on: *${take.description}*` : ""}`,
-
},
-
],
-
},
-
],
-
});
-
-
ts = res.ts;
-
} catch (error) {
-
console.error(
-
"Failed to notify user of completed session:",
-
error,
-
);
-
}
-
-
await db
-
.update(takesTable)
-
.set({
-
status: "waitingUpload",
-
completedAt: now,
-
elapsedTimeMs: elapsedTime,
-
ts,
-
notes: take.notes
-
? `${take.notes} (Automatically completed - time expired)`
-
: "Automatically completed - time expired",
-
})
-
.where(eq(takesTable.id, take.id));
-
}
-
}
-
}
···
+8 -12
src/features/takes/services/upload.ts
···
and(
eq(takesTable.userId, payload.user as string),
eq(takesTable.ts, payload.thread_ts as string),
-
eq(takesTable.status, "waitingUpload"),
),
);
···
await db
.update(takesTable)
.set({
-
status: "uploaded",
-
takeUploadedAt,
-
takeUrl: takePublicUrl,
})
.where(eq(takesTable.id, take.id));
···
elements: [
{
type: "mrkdwn",
-
text: `take by <@${user}> for \`${prettyPrintTime(take.elapsedTimeMs)}\` working on: *${take.description}*`,
},
],
},
···
type: "section",
text: {
type: "mrkdwn",
-
text: `:video_camera: new take uploaded by <@${user}> for \`${prettyPrintTime(take.elapsedTimeMs)}\` working on: *${take.description}*`,
},
},
{
···
},
{
type: "video",
-
video_url: `${process.env.API_URL}/api/video/${take.id}`,
-
title_url: `${process.env.API_URL}/api/video/${take.id}`,
title: {
type: "plain_text",
-
text: `${take.description} by <@${user}> uploaded at ${generateSlackDate(takeUploadedAt)}`,
},
thumbnail_url: `https://cachet.dunkirk.sh/users/${payload.user}/r`,
-
alt_text: `takes from ${takeUploadedAt?.toLocaleString("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })} uploaded with the description: *${take.description}*`,
},
{
type: "divider",
···
await db
.update(takesTable)
.set({
-
status: "approved",
multiplier: multiplier,
})
.where(eq(takesTable.id, takeId));
···
await db
.update(takesTable)
.set({
-
status: "rejected",
multiplier: "0",
})
.where(eq(takesTable.id, takeId));
···
and(
eq(takesTable.userId, payload.user as string),
eq(takesTable.ts, payload.thread_ts as string),
+
eq(takesTable.media, "[]"),
),
);
···
await db
.update(takesTable)
.set({
+
media: JSON.stringify([takePublicUrl]),
})
.where(eq(takesTable.id, take.id));
···
elements: [
{
type: "mrkdwn",
+
text: `take by <@${user}> for \`${prettyPrintTime(take.elapsedTimeMs)}\` working on: *${take.notes}*`,
},
],
},
···
type: "section",
text: {
type: "mrkdwn",
+
text: `:video_camera: new take uploaded by <@${user}> for \`${prettyPrintTime(take.elapsedTimeMs)}\` working on: *${take.notes}*`,
},
},
{
···
},
{
type: "video",
+
video_url: `${process.env.API_URL}/api/video/?media=${take.media[0]}`,
+
title_url: `${process.env.API_URL}/api/video/?media=${take.media[0]}`,
title: {
type: "plain_text",
+
text: `${take.notes} by <@${user}> uploaded at ${generateSlackDate(takeUploadedAt)}`,
},
thumbnail_url: `https://cachet.dunkirk.sh/users/${payload.user}/r`,
+
alt_text: `takes from ${takeUploadedAt?.toLocaleString("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })} uploaded with the description: *${take.notes}*`,
},
{
type: "divider",
···
await db
.update(takesTable)
.set({
multiplier: multiplier,
})
.where(eq(takesTable.id, takeId));
···
await db
.update(takesTable)
.set({
multiplier: "0",
})
.where(eq(takesTable.id, takeId));
-78
src/features/takes/setup/actions.ts
···
import { slackApp } from "../../../index";
-
import { db } from "../../../libs/db";
import { blog } from "../../../libs/Logger";
-
import { takes as takesTable } from "../../../libs/schema";
import handleHelp from "../handlers/help";
import { handleHistory } from "../handlers/history";
import handleHome from "../handlers/home";
-
import handlePause from "../handlers/pause";
-
import handleResume from "../handlers/resume";
-
import handleStart from "../handlers/start";
-
import handleStatus from "../handlers/status";
-
import handleStop from "../handlers/stop";
-
import { getActiveTake } from "../services/database";
import upload from "../services/upload";
import type { MessageResponse } from "../types";
-
import { getDescriptionBlocks, getEditDescriptionBlocks } from "../ui/blocks";
import * as Sentry from "@sentry/bun";
export default function setupActions() {
···
slackApp.action(/^takes_(\w+)$/, async ({ payload, context }) => {
try {
const userId = payload.user.id;
-
const channelId = context.channelId || "";
const actionId = payload.actions[0]?.action_id as string;
const command = actionId.replace("takes_", "");
-
const descriptionInput =
-
payload.state.values.note_block?.note_input;
let response: MessageResponse | undefined;
-
const activeTake = await getActiveTake(userId);
-
// Route to the appropriate handler function
switch (command) {
-
case "start": {
-
if (activeTake.length > 0) {
-
if (context.respond) {
-
response = await handleStatus(userId);
-
}
-
} else {
-
if (!descriptionInput?.value?.trim()) {
-
response = getDescriptionBlocks(
-
"Please enter a note for your session.",
-
);
-
} else {
-
response = await handleStart(
-
userId,
-
channelId,
-
descriptionInput?.value?.trim(),
-
);
-
}
-
}
-
break;
-
}
-
case "pause":
-
response = await handlePause(userId);
-
break;
-
case "resume":
-
response = await handleResume(userId);
-
break;
-
case "stop":
-
response = await handleStop(userId);
-
break;
-
case "edit": {
-
if (!activeTake.length && context.respond) {
-
await context.respond({
-
text: "You don't have an active takes session to edit!",
-
response_type: "ephemeral",
-
});
-
return;
-
}
-
-
if (!descriptionInput) {
-
response = getEditDescriptionBlocks(
-
activeTake[0]?.description || "",
-
);
-
} else if (descriptionInput.value?.trim()) {
-
const takeToUpdate = activeTake[0];
-
if (!takeToUpdate) return;
-
-
// Update the note for the active session
-
await db.update(takesTable).set({
-
description: descriptionInput.value.trim(),
-
});
-
-
response = await handleStatus(userId);
-
} else {
-
response = getEditDescriptionBlocks(
-
"",
-
"Please enter a note for your session.",
-
);
-
}
-
break;
-
}
-
-
case "status":
-
response = await handleStatus(userId);
-
break;
case "history":
response = await handleHistory(userId);
break;
···
import { slackApp } from "../../../index";
import { blog } from "../../../libs/Logger";
import handleHelp from "../handlers/help";
import { handleHistory } from "../handlers/history";
import handleHome from "../handlers/home";
import upload from "../services/upload";
import type { MessageResponse } from "../types";
import * as Sentry from "@sentry/bun";
export default function setupActions() {
···
slackApp.action(/^takes_(\w+)$/, async ({ payload, context }) => {
try {
const userId = payload.user.id;
const actionId = payload.actions[0]?.action_id as string;
const command = actionId.replace("takes_", "");
let response: MessageResponse | undefined;
// Route to the appropriate handler function
switch (command) {
case "history":
response = await handleHistory(userId);
break;
+1 -75
src/features/takes/setup/commands.ts
···
import { environment, slackApp } from "../../../index";
import handleHelp from "../handlers/help";
import { handleHistory } from "../handlers/history";
-
import handlePause from "../handlers/pause";
-
import handleResume from "../handlers/resume";
-
import handleStart from "../handlers/start";
-
import handleStatus from "../handlers/status";
-
import handleStop from "../handlers/stop";
-
import { getActiveTake, getPausedTake } from "../services/database";
-
import {
-
checkActiveSessions,
-
expirePausedSessions,
-
} from "../services/notifications";
import type { MessageResponse } from "../types";
-
import { getDescriptionBlocks, getEditDescriptionBlocks } from "../ui/blocks";
import * as Sentry from "@sentry/bun";
import { blog } from "../../../libs/Logger";
import handleHome from "../handlers/home";
···
const channelId = payload.channel_id;
const text = payload.text || "";
const args = text.trim().split(/\s+/);
-
let subcommand = args[0]?.toLowerCase() || "";
-
-
// Check for active takes session
-
const activeTake = await getActiveTake(userId);
-
-
// Check for paused session if no active one
-
const pausedTakeCheck =
-
activeTake.length === 0 ? await getPausedTake(userId) : [];
-
-
// Run checks for expired or about-to-expire sessions
-
await expirePausedSessions();
-
await checkActiveSessions();
-
-
// Default to status if we have an active or paused session and no command specified
-
if (
-
subcommand === "" &&
-
(activeTake.length > 0 || pausedTakeCheck.length > 0)
-
) {
-
subcommand = "status";
-
}
let response: MessageResponse | undefined;
-
// Special handling for start command to show modal
-
if (subcommand === "start" && !activeTake.length) {
-
response = getDescriptionBlocks();
-
}
-
// Route to the appropriate handler function
switch (subcommand) {
-
case "start": {
-
if (args.length < 2) {
-
response = getDescriptionBlocks();
-
break;
-
}
-
-
const descriptionInput = args.slice(1).join(" ");
-
-
if (!descriptionInput.trim()) {
-
response = getDescriptionBlocks(
-
"Please enter a note for your session.",
-
);
-
break;
-
}
-
-
response = await handleStart(
-
userId,
-
channelId,
-
descriptionInput,
-
);
-
break;
-
}
-
case "pause":
-
response = await handlePause(userId);
-
break;
-
case "resume":
-
response = await handleResume(userId);
-
break;
-
case "stop":
-
response = await handleStop(userId, args);
-
break;
-
case "edit":
-
response = getEditDescriptionBlocks(
-
activeTake[0]?.description || "",
-
);
-
break;
-
case "status":
-
response = await handleStatus(userId);
-
break;
case "history":
response = await handleHistory(userId);
break;
···
import { environment, slackApp } from "../../../index";
import handleHelp from "../handlers/help";
import { handleHistory } from "../handlers/history";
import type { MessageResponse } from "../types";
import * as Sentry from "@sentry/bun";
import { blog } from "../../../libs/Logger";
import handleHome from "../handlers/home";
···
const channelId = payload.channel_id;
const text = payload.text || "";
const args = text.trim().split(/\s+/);
+
const subcommand = args[0]?.toLowerCase() || "";
let response: MessageResponse | undefined;
// Route to the appropriate handler function
switch (subcommand) {
case "history":
response = await handleHistory(userId);
break;
-47
src/features/takes/setup/notifications.ts
···
-
import * as Sentry from "@sentry/bun";
-
import TakesConfig from "../../../libs/config";
-
import { blog } from "../../../libs/Logger";
-
import {
-
checkActiveSessions,
-
expirePausedSessions,
-
} from "../services/notifications";
-
-
export default function setupNotifications() {
-
try {
-
const notificationInterval = TakesConfig.NOTIFICATIONS.CHECK_INTERVAL;
-
-
setInterval(async () => {
-
try {
-
await checkActiveSessions();
-
await expirePausedSessions();
-
} catch (error) {
-
if (error instanceof Error)
-
blog(
-
`Error in notifications check: ${error.message}`,
-
"error",
-
);
-
Sentry.captureException(error, {
-
extra: {
-
context: "notifications check",
-
checkInterval: notificationInterval,
-
},
-
tags: {
-
type: "notification_check",
-
},
-
});
-
}
-
}, notificationInterval);
-
} catch (error) {
-
if (error instanceof Error)
-
blog(`Error setting up notifications: ${error.message}`, "error");
-
Sentry.captureException(error, {
-
extra: {
-
context: "notifications setup",
-
},
-
tags: {
-
type: "notification_setup",
-
},
-
});
-
throw error; // Re-throw to prevent the app from starting with broken notifications
-
}
-
}
···
-148
src/features/takes/ui/blocks.ts
···
-
import type { AnyMessageBlock } from "slack-edge";
-
import type { MessageResponse } from "../types";
-
-
export function getDescriptionBlocks(error?: string): MessageResponse {
-
const blocks: AnyMessageBlock[] = [
-
{
-
type: "input",
-
block_id: "note_block",
-
element: {
-
type: "plain_text_input",
-
action_id: "note_input",
-
placeholder: {
-
type: "plain_text",
-
text: "Enter a note for your session",
-
},
-
multiline: true,
-
},
-
label: {
-
type: "plain_text",
-
text: "Note",
-
},
-
},
-
{
-
type: "actions",
-
elements: [
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "🎬 Start Session",
-
emoji: true,
-
},
-
value: "start",
-
action_id: "takes_start",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "⛔ Cancel",
-
emoji: true,
-
},
-
value: "cancel",
-
action_id: "takes_status",
-
style: "danger",
-
},
-
],
-
},
-
];
-
-
if (error) {
-
blocks.push(
-
{
-
type: "divider",
-
},
-
{
-
type: "context",
-
elements: [
-
{
-
type: "mrkdwn",
-
text: `⚠️ ${error}`,
-
},
-
],
-
},
-
);
-
}
-
-
return {
-
text: "Please enter a note for your session:",
-
response_type: "ephemeral",
-
blocks,
-
};
-
}
-
-
export function getEditDescriptionBlocks(
-
description: string,
-
error?: string,
-
): MessageResponse {
-
const blocks: AnyMessageBlock[] = [
-
{
-
type: "input",
-
block_id: "note_block",
-
element: {
-
type: "plain_text_input",
-
action_id: "note_input",
-
placeholder: {
-
type: "plain_text",
-
text: "Enter a note for your session",
-
},
-
multiline: true,
-
initial_value: description,
-
},
-
label: {
-
type: "plain_text",
-
text: "Note",
-
},
-
},
-
{
-
type: "actions",
-
elements: [
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "✍️ Update Note",
-
emoji: true,
-
},
-
value: "start",
-
action_id: "takes_edit",
-
},
-
{
-
type: "button",
-
text: {
-
type: "plain_text",
-
text: "⛔ Cancel",
-
emoji: true,
-
},
-
value: "cancel",
-
action_id: "takes_status",
-
style: "danger",
-
},
-
],
-
},
-
];
-
-
if (error) {
-
blocks.push(
-
{
-
type: "divider",
-
},
-
{
-
type: "context",
-
elements: [
-
{
-
type: "mrkdwn",
-
text: `⚠️ ${error}`,
-
},
-
],
-
},
-
);
-
}
-
-
return {
-
text: "Please enter a note for your session:",
-
response_type: "ephemeral",
-
blocks,
-
};
-
}
···
+9 -10
src/libs/db.ts
···
-
import { drizzle } from "drizzle-orm/bun-sqlite";
-
import { Database } from "bun:sqlite";
import * as schema from "./schema";
-
// Use environment variable for the database path in production
-
const dbPath = process.env.DATABASE_PATH || "./local.db";
-
// Create a SQLite database instance using Bun's built-in driver
-
const sqlite = new Database(dbPath);
-
// Create a Drizzle instance with the database and schema
-
export const db = drizzle(sqlite, { schema });
-
// Export the sqlite instance and schema for use in other files
-
export { sqlite, schema };
···
+
import { drizzle } from "drizzle-orm/node-postgres";
+
import { Pool } from "pg";
import * as schema from "./schema";
+
const pool = new Pool({
+
connectionString: process.env.DATABASE_URL,
+
});
+
export const db = drizzle(pool, { schema });
+
// Set up triggers when initializing the database
+
schema.setupTriggers(pool).catch(console.error);
+
export { pool, schema };
+52 -21
src/libs/schema.ts
···
-
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
// Define the takes table
-
export const takes = sqliteTable("takes", {
id: text("id").primaryKey(),
userId: text("user_id").notNull(),
-
ts: text("ts"),
-
status: text("status").notNull().default("active"), // active, paused, waitingUpload, completed
elapsedTimeMs: integer("elapsed_time_ms").notNull().default(0),
-
targetDurationMs: integer("target_duration_ms").notNull(),
-
periods: text("periods").notNull(), // JSON string of time periods
-
lastResumeAt: integer("last_resume_at", { mode: "timestamp" }),
-
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
-
() => new Date(),
-
),
-
completedAt: integer("completed_at", { mode: "timestamp" }),
-
takeUploadedAt: integer("take_uploaded_at", { mode: "timestamp" }),
-
takeUrl: text("take_url"),
multiplier: text("multiplier").notNull().default("1.0"),
-
notes: text("notes"),
-
description: text("description"),
-
notifiedLowTime: integer("notified_low_time", { mode: "boolean" }).default(
-
false,
-
), // has user been notified about low time
-
notifiedPauseExpiration: integer("notified_pause_expiration", {
-
mode: "boolean",
-
}).default(false), // has user been notified about pause expiration
});
···
+
import { pgTable, text, integer } from "drizzle-orm/pg-core";
+
import type { Pool } from "pg";
// Define the takes table
+
export const takes = pgTable("takes", {
id: text("id").primaryKey(),
userId: text("user_id").notNull(),
+
ts: text("ts").notNull(),
elapsedTimeMs: integer("elapsed_time_ms").notNull().default(0),
+
createdAt: integer("created_at")
+
.$defaultFn(() => Math.floor(new Date().getTime() / 1000))
+
.notNull(),
+
media: text("media").notNull().default("[]"), // array of media urls
multiplier: text("multiplier").notNull().default("1.0"),
+
notes: text("notes").notNull().default(""),
+
});
+
+
export const users = pgTable("users", {
+
id: text("id").primaryKey(),
+
totalTakesTime: integer("total_takes_time").default(0),
});
+
+
export async function setupTriggers(pool: Pool) {
+
await pool.query(`
+
CREATE INDEX IF NOT EXISTS idx_takes_user_id ON takes(user_id);
+
+
CREATE OR REPLACE FUNCTION update_user_total_time()
+
RETURNS TRIGGER AS $$
+
BEGIN
+
IF TG_OP = 'INSERT' THEN
+
UPDATE users
+
SET total_takes_time = COALESCE(total_takes_time, 0) + NEW.elapsed_time_ms
+
WHERE id = NEW.user_id;
+
ELSIF TG_OP = 'DELETE' THEN
+
UPDATE users
+
SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time_ms
+
WHERE id = OLD.user_id;
+
ELSIF TG_OP = 'UPDATE' THEN
+
UPDATE users
+
SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time_ms + NEW.elapsed_time_ms
+
WHERE id = NEW.user_id;
+
END IF;
+
+
EXCEPTION WHEN OTHERS THEN
+
RAISE NOTICE 'Error updating user total time: %', SQLERRM;
+
RETURN NULL;
+
+
RETURN NEW;
+
END;
+
$$ LANGUAGE plpgsql;
+
+
DROP TRIGGER IF EXISTS update_user_total_time_trigger ON takes;
+
+
CREATE TRIGGER update_user_total_time_trigger
+
AFTER INSERT OR UPDATE OR DELETE ON takes
+
FOR EACH ROW
+
EXECUTE FUNCTION update_user_total_time();
+
`);
+
}
-78
src/libs/time-periods.ts
···
-
import type { PeriodType, TimePeriod } from "../features/takes/types";
-
import TakesConfig from "./config";
-
-
export function calculateElapsedTime(periods: TimePeriod[]): number {
-
return Math.min(
-
periods.reduce((total, period) => {
-
if (period.type !== "active") return total;
-
-
const endTime = period.endTime || Date.now();
-
return total + (endTime - period.startTime);
-
}, 0),
-
TakesConfig.DEFAULT_SESSION_LENGTH * 60 * 1000,
-
);
-
}
-
-
export function addNewPeriod(
-
periodsString: string,
-
type: PeriodType,
-
): TimePeriod[] {
-
const periods = JSON.parse(periodsString);
-
-
// Close previous period if exists
-
if (periods.length > 0) {
-
const lastPeriod = periods[periods.length - 1];
-
if (!lastPeriod.endTime) {
-
lastPeriod.endTime = Date.now();
-
}
-
}
-
-
// Add new period
-
periods.push({
-
type,
-
startTime: Date.now(),
-
endTime: null,
-
});
-
-
return periods;
-
}
-
-
export function getRemainingTime(
-
targetDurationMs: number,
-
periods: string,
-
): {
-
remaining: number;
-
endTime: Date;
-
} {
-
const elapsedMs = calculateElapsedTime(JSON.parse(periods));
-
const remaining = Math.max(0, targetDurationMs - elapsedMs);
-
const endTime = new Date(Date.now() + remaining);
-
return { remaining, endTime };
-
}
-
-
export function getPausedTimeRemaining(periods: string): number {
-
const parsedPeriods = JSON.parse(periods);
-
const currentPeriod = parsedPeriods[parsedPeriods.length - 1];
-
-
if (currentPeriod.type !== "paused" || !currentPeriod.startTime) {
-
return 0;
-
}
-
-
const now = new Date();
-
const pausedDuration = now.getTime() - currentPeriod.startTime;
-
-
return Math.max(
-
0,
-
TakesConfig.MAX_PAUSE_DURATION * 60 * 1000 - pausedDuration,
-
);
-
}
-
-
export function getPausedDuration(periods: string): number {
-
const parsedPeriods = JSON.parse(periods);
-
return parsedPeriods.reduce((total: number, period: TimePeriod) => {
-
if (period.type !== "paused") return total;
-
-
const endTime = period.endTime || Date.now();
-
return total + (endTime - period.startTime);
-
}, 0);
-
}
···