AI Update: Started backend platform server on port 8080

This commit is contained in:
Noah AI
2026-06-29 13:20:41 +00:00
parent aa81c87150
commit 994040e004
17 changed files with 1202 additions and 0 deletions

3
backend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.git
.DS_Store

13
backend/.env Normal file
View File

@@ -0,0 +1,13 @@
PORT=8080
NODE_ENV=development
DATABASE_URL=libsql://db-dev-63adec08f5fd35eadac3-sumit22.aws-ap-south-1.turso.io
DATABASE_AUTH_TOKEN=eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhIjoicnciLCJpYXQiOjE3ODI3Mzg5MjcsImlkIjoiMDE5ZjEzODUtMzUwMS03ODA0LWExZTEtY2JlN2E4NDlhM2I2Iiwia2lkIjoiczltT1N1V3ZwX2pib21leUZZNzR3RVNoN1NOak12RmVDc3EtZk5YdHY1NCIsInJpZCI6IjU5OTg0M2RkLTc4NWItNDhhZS04MDI0LTgxODM2YmQzZDE0MSJ9.TsozKx4lkITuiGiQ7FQDR_xl1LiZtzExx6nDkrZgefNXI7QPWiKYrSTgapqNMlO9QiGzE_cPbcClHvCPnmgMDg
S3_ENDPOINT=https://t3.storage.dev
S3_BUCKET=plena-staging
S3_PREFIX=backends/6892099c9a4be35629c49e8c/6a426fea762485cd9a634857/dev/
S3_ACCESS_KEY_ID=tid_VozbolUlzrxAAhDfPdanwUzAMqsO_E_EroKpPZnCcwOuXlHUKd
S3_SECRET_ACCESS_KEY=tsec_C6sLdJDir0Kz+4abePDcJIdah+zA7dni1Z5nNY3lH5f4-dd+Nr+GohZuXgZwetr1Hv-270
S3_SESSION_TOKEN=
PROJECT_ID=6a426fea762485cd9a634857
CORS_ORIGIN=*
LOG_LEVEL=debug

14
backend/.env.example Normal file
View File

@@ -0,0 +1,14 @@
PORT=8080
NODE_ENV=development
API_PREFIX=
CORS_ORIGIN=*
LOG_LEVEL=debug
PROJECT_ID=template-project
DATABASE_URL=libsql://your-db.turso.io
DATABASE_AUTH_TOKEN=replace-me
S3_ENDPOINT=https://t3.storage.dev
S3_BUCKET=replace-me
S3_PREFIX=backends/template-project/
S3_ACCESS_KEY_ID=replace-me
S3_SECRET_ACCESS_KEY=replace-me
S3_SESSION_TOKEN=

2
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
.DS_Store

15
backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM oven/bun:1.2.18 AS base
WORKDIR /app
COPY package.json bun.lock tsconfig.json ./
RUN bun install --frozen-lockfile
COPY src ./src
ENV NODE_ENV=production
ENV PORT=8080
EXPOSE 8080
CMD ["bun", "src/index.ts"]

59
backend/README.md Normal file
View File

@@ -0,0 +1,59 @@
# Backend Todo App Template
Backend-only Bun + Express template intended to be snapshotted directly and mounted at `/mnt/backend`.
## What it provides
- `GET /health`
- `GET /todos`
- `POST /todos`
- `GET /todos/:id`
- `PUT /todos/:id`
- `DELETE /todos/:id`
- `POST /todos/:id/attachment/presign`
- `POST /todos/:id/attachment/complete`
Todos are stored in Turso/libSQL through the HTTP pipeline API. Attachments use Tigris or any S3-compatible endpoint through presigned `PUT` URLs.
## Runtime contract
- app root is this folder
- start command: `bun run dev`
- listens on `PORT`, default `8080`
- compatible with a direct snapshot mounted to `/mnt/backend`
## Setup
```bash
cp .env.example .env
bun install
bun run dev
```
## Required env
`.env` and `.env.example` contain the full key set expected by the app:
- `PORT`
- `NODE_ENV`
- `API_PREFIX`
- `CORS_ORIGIN`
- `LOG_LEVEL`
- `PROJECT_ID`
- `DATABASE_URL`
- `DATABASE_AUTH_TOKEN`
- `S3_ENDPOINT`
- `S3_BUCKET`
- `S3_PREFIX`
- `S3_ACCESS_KEY_ID`
- `S3_SECRET_ACCESS_KEY`
- `S3_SESSION_TOKEN`
## Attachment flow
1. Create a todo with `POST /todos`.
2. Request a presigned upload target with `POST /todos/:id/attachment/presign`.
3. Upload the file directly to the returned `url` using the returned `method` and `headers`.
4. Finalize the attachment metadata with `POST /todos/:id/attachment/complete`.
Deleting a todo only removes the database record in v1. It does not delete the object from storage.

187
backend/bun.lock Normal file
View File

@@ -0,0 +1,187 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "backend-todo-app-template",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.0",
"express": "^5.1.0",
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.0.7",
"typescript": "^5.8.3",
},
},
},
"packages": {
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
}
}

22
backend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "backend-todo-app-template",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.0",
"express": "^5.1.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.0.7",
"typescript": "^5.8.3"
}
}

81
backend/src/config.ts Normal file
View File

@@ -0,0 +1,81 @@
const REQUIRED_ENV_KEYS = [
'PROJECT_ID',
'DATABASE_URL',
'DATABASE_AUTH_TOKEN',
'S3_ENDPOINT',
'S3_BUCKET',
'S3_PREFIX',
'S3_ACCESS_KEY_ID',
'S3_SECRET_ACCESS_KEY',
] as const;
const PLACEHOLDER_PATTERNS = [/^replace-me$/i, /^libsql:\/\/your-db\.turso\.io$/i];
function normalizeOptional(value: string | undefined): string {
return (value ?? '').trim();
}
function normalizePrefix(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
return trimmed.startsWith('/') ? trimmed.replace(/\/+$/, '') : `/${trimmed.replace(/\/+$/, '')}`;
}
function isPlaceholder(value: string): boolean {
return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value));
}
function buildMissingEnv(env: Record<string, string | undefined>): string[] {
return REQUIRED_ENV_KEYS.filter((key) => {
const value = normalizeOptional(env[key]);
return !value || isPlaceholder(value);
});
}
export type AppConfig = {
port: number;
nodeEnv: string;
apiPrefix: string;
corsOrigin: string;
logLevel: string;
projectId: string;
databaseUrl: string;
databaseAuthToken: string;
s3Endpoint: string;
s3Bucket: string;
s3Prefix: string;
s3AccessKeyId: string;
s3SecretAccessKey: string;
s3SessionToken: string;
missingEnv: string[];
};
export function getConfig(env: Record<string, string | undefined> = process.env): AppConfig {
const port = Number(env.PORT || '8080');
return {
port: Number.isFinite(port) ? port : 8080,
nodeEnv: env.NODE_ENV || 'development',
apiPrefix: normalizePrefix(env.API_PREFIX || ''),
corsOrigin: env.CORS_ORIGIN || '*',
logLevel: env.LOG_LEVEL || 'debug',
projectId: env.PROJECT_ID || 'template-project',
databaseUrl: normalizeOptional(env.DATABASE_URL),
databaseAuthToken: normalizeOptional(env.DATABASE_AUTH_TOKEN),
s3Endpoint: env.S3_ENDPOINT || 'https://t3.storage.dev',
s3Bucket: normalizeOptional(env.S3_BUCKET),
s3Prefix: env.S3_PREFIX || '',
s3AccessKeyId: normalizeOptional(env.S3_ACCESS_KEY_ID),
s3SecretAccessKey: normalizeOptional(env.S3_SECRET_ACCESS_KEY),
s3SessionToken: normalizeOptional(env.S3_SESSION_TOKEN),
missingEnv: buildMissingEnv(env),
};
}
export function buildApiPath(config: AppConfig, path: string): string {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${config.apiPrefix}${normalizedPath}` || normalizedPath;
}

37
backend/src/index.ts Normal file
View File

@@ -0,0 +1,37 @@
import 'dotenv/config';
import cors from 'cors';
import express, { type NextFunction, type Request, type Response } from 'express';
import { buildApiPath, getConfig } from './config';
import { TursoClient } from './lib/db';
import { createHealthRouter } from './routes/health';
import { createTodosRouter } from './routes/todos';
const config = getConfig();
const app = express();
const db = new TursoClient(config);
app.disable('x-powered-by');
app.use(cors({ origin: config.corsOrigin === '*' ? true : config.corsOrigin }));
app.use(express.json({ limit: '1mb' }));
const apiRoot = config.apiPrefix || '/';
app.use(apiRoot, createHealthRouter(config));
app.use(apiRoot, createTodosRouter(config, db));
app.use((_req, res) => {
res.status(404).json({ success: false, error: 'Not found' });
});
app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => {
const status = /required|invalid|missing/i.test(error.message) ? 400 : 500;
res.status(status).json({
success: false,
error: error.message || 'Unhandled error',
});
});
app.listen(config.port, '0.0.0.0', () => {
const healthPath = buildApiPath(config, '/health');
console.log(`Backend todo template listening on port ${config.port}`);
console.log(`Health check available at ${healthPath}`);
});

158
backend/src/lib/db.ts Normal file
View File

@@ -0,0 +1,158 @@
import type { AppConfig } from '../config';
import { TODOS_TABLE_SQL } from './todos-schema';
type TursoPipelineResponse = {
results?: Array<{
type?: string;
response?: {
result?: {
cols?: Array<{ name?: string } | string>;
rows?: unknown[][];
affected_row_count?: number;
};
error?: {
message?: string;
};
};
}>;
};
function getTursoHttpBase(databaseUrl: string): string {
if (!databaseUrl) {
return '';
}
if (databaseUrl.startsWith('libsql://')) {
return `https://${databaseUrl.slice('libsql://'.length)}`;
}
return databaseUrl;
}
function extractCellValue(cell: unknown): unknown {
if (cell == null) {
return null;
}
if (typeof cell === 'object') {
const cellObj = cell as Record<string, unknown>;
if (cellObj.type === 'null') {
return null;
}
if ('value' in cellObj) {
return cellObj.value;
}
if ('base64' in cellObj) {
return cellObj.base64;
}
}
return cell;
}
function formatSqlString(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
export function sqlValue(value: string | number | boolean | null | undefined): string {
if (value == null) {
return 'NULL';
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
throw new Error('Invalid numeric SQL value');
}
return String(value);
}
if (typeof value === 'boolean') {
return value ? '1' : '0';
}
return formatSqlString(value);
}
export class TursoClient {
private readonly baseUrl: string;
constructor(private readonly config: AppConfig) {
this.baseUrl = getTursoHttpBase(config.databaseUrl);
}
private assertConfigured() {
if (!this.baseUrl || !this.config.databaseAuthToken) {
throw new Error('Database env missing');
}
}
async execute(sql: string): Promise<TursoPipelineResponse> {
this.assertConfigured();
const response = await fetch(`${this.baseUrl}/v2/pipeline`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.databaseAuthToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
requests: [
{
type: 'execute',
stmt: { sql },
},
],
}),
});
const text = await response.text();
let payload: TursoPipelineResponse | { raw: string };
try {
payload = JSON.parse(text) as TursoPipelineResponse;
} catch {
payload = { raw: text };
}
if (!response.ok) {
const details = typeof payload === 'object' ? JSON.stringify(payload) : text;
throw new Error(`Turso query failed: ${details}`);
}
const typedPayload = payload as TursoPipelineResponse;
const resultObj = typedPayload.results?.[0];
const errorMessage = (resultObj as any)?.response?.error?.message || (resultObj as any)?.error?.message;
if (errorMessage) {
throw new Error(`Turso query failed: ${errorMessage}`);
}
return typedPayload;
}
async rows(sql: string): Promise<Record<string, unknown>[]> {
const response = await this.execute(sql);
const result = response.results?.[0]?.response?.result;
const cols = result?.cols ?? [];
const rows = result?.rows ?? [];
const columnNames = cols.map((col) => (typeof col === 'string' ? col : col.name || ''));
return rows.map((row) => {
const record: Record<string, unknown> = {};
row.forEach((cell, index) => {
record[columnNames[index] || String(index)] = extractCellValue(cell);
});
return record;
});
}
async first(sql: string): Promise<Record<string, unknown> | null> {
const rows = await this.rows(sql);
return rows[0] ?? null;
}
async ensureTodosTable(): Promise<void> {
await this.execute(TODOS_TABLE_SQL);
}
}

139
backend/src/lib/storage.ts Normal file
View File

@@ -0,0 +1,139 @@
import { createHash, createHmac } from 'node:crypto';
import type { AppConfig } from '../config';
import type { PresignedUpload } from '../types/todo';
function hashHex(data: string): string {
return createHash('sha256').update(data).digest('hex');
}
function hmac(key: Buffer | string, data: string, encoding?: 'hex'): Buffer | string {
const digest = createHmac('sha256', key).update(data).digest();
return encoding ? digest.toString(encoding) : digest;
}
function encodeRfc3986(value: string): string {
return encodeURIComponent(value).replace(/[!'()*]/g, (char) =>
`%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
function toAmzDate(date: Date): string {
return date.toISOString().replace(/[:-]|\.\d{3}/g, '');
}
function buildScope(shortDate: string, region: string, service: string): string {
return `${shortDate}/${region}/${service}/aws4_request`;
}
function signString(secretAccessKey: string, shortDate: string, region: string, service: string, stringToSign: string): string {
const kDate = hmac(`AWS4${secretAccessKey}`, shortDate) as Buffer;
const kRegion = hmac(kDate, region) as Buffer;
const kService = hmac(kRegion, service) as Buffer;
const kSigning = hmac(kService, 'aws4_request') as Buffer;
return hmac(kSigning, stringToSign, 'hex') as string;
}
function sanitizeFilename(filename: string): string {
const trimmed = filename.trim();
const base = trimmed || 'attachment.bin';
return base.replace(/[^a-zA-Z0-9._-]/g, '_');
}
function normalizePrefix(prefix: string): string {
const trimmed = prefix.trim();
if (!trimmed) {
return '';
}
return trimmed.replace(/^\/+/, '').replace(/\/+$/, '');
}
function joinKey(...parts: string[]): string {
return parts
.map((part) => part.trim())
.filter(Boolean)
.map((part) => part.replace(/^\/+/, '').replace(/\/+$/, ''))
.join('/');
}
function encodeObjectPath(bucket: string, key: string): string {
const encodedKey = key
.split('/')
.filter(Boolean)
.map((segment) => encodeRfc3986(segment))
.join('/');
return `/${encodeRfc3986(bucket)}${encodedKey ? `/${encodedKey}` : ''}`;
}
export function buildAttachmentUrl(config: AppConfig, key: string): string {
const endpoint = new URL(config.s3Endpoint);
const path = encodeObjectPath(config.s3Bucket, key);
return `${endpoint.origin}${path}`;
}
export function createAttachmentKey(config: AppConfig, todoId: number, filename: string): string {
const prefix = normalizePrefix(config.s3Prefix);
const safeFilename = sanitizeFilename(filename);
return joinKey(prefix, 'todos', String(todoId), `${Date.now()}-${safeFilename}`);
}
export function presignTodoAttachmentUpload(
config: AppConfig,
todoId: number,
filename: string,
contentType?: string,
): PresignedUpload {
if (!config.s3Bucket || !config.s3AccessKeyId || !config.s3SecretAccessKey) {
throw new Error('Storage env missing');
}
const endpoint = new URL(config.s3Endpoint);
const region = 'auto';
const service = 's3';
const now = new Date();
const amzDate = toAmzDate(now);
const shortDate = amzDate.slice(0, 8);
const key = createAttachmentKey(config, todoId, filename);
const canonicalUri = encodeObjectPath(config.s3Bucket, key);
const scope = buildScope(shortDate, region, service);
const params: Record<string, string> = {
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${config.s3AccessKeyId}/${scope}`,
'X-Amz-Date': amzDate,
'X-Amz-Expires': '900',
'X-Amz-SignedHeaders': 'host',
};
if (config.s3SessionToken) {
params['X-Amz-Security-Token'] = config.s3SessionToken;
}
const canonicalQuery = Object.keys(params)
.sort()
.map((name) => `${encodeRfc3986(name)}=${encodeRfc3986(params[name])}`)
.join('&');
const canonicalRequest = [
'PUT',
canonicalUri,
canonicalQuery,
`host:${endpoint.host}\n`,
'host',
'UNSIGNED-PAYLOAD',
].join('\n');
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, hashHex(canonicalRequest)].join('\n');
const signature = signString(config.s3SecretAccessKey, shortDate, region, service, stringToSign);
const url = `${endpoint.origin}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
return {
url,
method: 'PUT',
headers: {
'content-type': contentType || 'application/octet-stream',
},
key,
attachmentUrl: buildAttachmentUrl(config, key),
};
}

View File

@@ -0,0 +1,74 @@
import type { Todo, TodoAttachment } from '../types/todo';
export const TODOS_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
completed INTEGER NOT NULL DEFAULT 0,
attachment_key TEXT,
attachment_filename TEXT,
attachment_content_type TEXT,
attachment_size_bytes INTEGER,
attachment_url TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
export const TODO_SELECT_COLUMNS = [
'id',
'title',
'description',
'completed',
'attachment_key',
'attachment_filename',
'attachment_content_type',
'attachment_size_bytes',
'attachment_url',
'created_at',
'updated_at',
].join(', ');
function toNullableString(value: unknown): string | null {
return value == null ? null : String(value);
}
function toNullableNumber(value: unknown): number | null {
if (value == null || value === '') {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function toAttachment(row: Record<string, unknown>): TodoAttachment | null {
const key = toNullableString(row.attachment_key);
const filename = toNullableString(row.attachment_filename);
const url = toNullableString(row.attachment_url);
if (!key || !filename || !url) {
return null;
}
return {
key,
filename,
contentType: toNullableString(row.attachment_content_type),
sizeBytes: toNullableNumber(row.attachment_size_bytes),
url,
};
}
export function mapTodoRow(row: Record<string, unknown>): Todo {
return {
id: Number(row.id),
title: String(row.title ?? ''),
description: toNullableString(row.description),
completed: Number(row.completed ?? 0) === 1,
attachment: toAttachment(row),
createdAt: String(row.created_at ?? ''),
updatedAt: String(row.updated_at ?? ''),
};
}

View File

@@ -0,0 +1,24 @@
import { Router } from 'express';
import type { AppConfig } from '../config';
export function createHealthRouter(config: AppConfig): Router {
const router = Router();
router.get('/health', (_req, res) => {
const ready = config.missingEnv.length === 0;
res.status(200).json({
status: 'ok',
projectId: config.projectId,
ready,
config: {
apiPrefix: config.apiPrefix,
databaseConfigured: !!config.databaseUrl && !!config.databaseAuthToken,
storageConfigured: !!config.s3Bucket && !!config.s3AccessKeyId && !!config.s3SecretAccessKey,
missingEnv: config.missingEnv,
},
});
});
return router;
}

331
backend/src/routes/todos.ts Normal file
View File

@@ -0,0 +1,331 @@
import { Router, type Request, type Response, type NextFunction } from 'express';
import type { AppConfig } from '../config';
import { TursoClient, sqlValue } from '../lib/db';
import { TODO_SELECT_COLUMNS, mapTodoRow } from '../lib/todos-schema';
import { presignTodoAttachmentUpload } from '../lib/storage';
import type { TodoAttachment } from '../types/todo';
type AttachmentUpdateInput =
| { mode: 'keep' }
| { mode: 'clear' }
| { mode: 'replace'; attachment: TodoAttachment };
function asyncHandler(handler: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
return (req: Request, res: Response, next: NextFunction) => {
void handler(req, res, next).catch(next);
};
}
function normalizeTodoId(rawId: string | string[] | undefined): number | null {
if (Array.isArray(rawId)) {
return null;
}
const id = Number(rawId);
return Number.isInteger(id) && id > 0 ? id : null;
}
function readString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
function parseAttachmentUpdate(body: Record<string, unknown>): AttachmentUpdateInput {
if (!Object.prototype.hasOwnProperty.call(body, 'attachment')) {
return { mode: 'keep' };
}
if (body.attachment == null) {
return { mode: 'clear' };
}
if (typeof body.attachment !== 'object') {
throw new Error('attachment must be an object or null');
}
const attachment = body.attachment as Record<string, unknown>;
const key = readString(attachment.key)?.trim();
const filename = readString(attachment.filename)?.trim();
const url = readString(attachment.url)?.trim();
const contentType = readString(attachment.contentType)?.trim() || null;
const sizeBytes = attachment.sizeBytes == null ? null : Number(attachment.sizeBytes);
if (!key || !filename || !url) {
throw new Error('attachment.key, attachment.filename, and attachment.url are required');
}
if (sizeBytes != null && !Number.isFinite(sizeBytes)) {
throw new Error('attachment.sizeBytes must be a number');
}
return {
mode: 'replace',
attachment: {
key,
filename,
contentType,
sizeBytes,
url,
},
};
}
function applyAttachmentUpdate(attachmentUpdate: AttachmentUpdateInput, updates: string[]) {
if (attachmentUpdate.mode === 'keep') {
return;
}
if (attachmentUpdate.mode === 'clear') {
updates.push(
'attachment_key = NULL',
'attachment_filename = NULL',
'attachment_content_type = NULL',
'attachment_size_bytes = NULL',
'attachment_url = NULL',
);
return;
}
updates.push(
`attachment_key = ${sqlValue(attachmentUpdate.attachment.key)}`,
`attachment_filename = ${sqlValue(attachmentUpdate.attachment.filename)}`,
`attachment_content_type = ${sqlValue(attachmentUpdate.attachment.contentType)}`,
`attachment_size_bytes = ${sqlValue(attachmentUpdate.attachment.sizeBytes)}`,
`attachment_url = ${sqlValue(attachmentUpdate.attachment.url)}`,
);
}
async function requireTodo(db: TursoClient, id: number) {
const row = await db.first(`SELECT ${TODO_SELECT_COLUMNS} FROM todos WHERE id = ${id} LIMIT 1`);
return row ? mapTodoRow(row) : null;
}
export function createTodosRouter(config: AppConfig, db: TursoClient): Router {
const router = Router();
router.use(
asyncHandler(async (_req, _res, next) => {
await db.ensureTodosTable();
next();
}),
);
router.get(
'/todos',
asyncHandler(async (_req, res) => {
const rows = await db.rows(`SELECT ${TODO_SELECT_COLUMNS} FROM todos ORDER BY id DESC`);
res.status(200).json({
success: true,
todos: rows.map(mapTodoRow),
});
}),
);
router.post(
'/todos',
asyncHandler(async (req, res) => {
const title = readString(req.body?.title)?.trim() || '';
const description = readString(req.body?.description);
if (!title) {
res.status(400).json({ success: false, error: 'title is required' });
return;
}
const row = await db.first(
`
INSERT INTO todos (title, description, completed, updated_at)
VALUES (${sqlValue(title)}, ${sqlValue(description ?? null)}, 0, CURRENT_TIMESTAMP)
RETURNING ${TODO_SELECT_COLUMNS}
`,
);
res.status(201).json({
success: true,
todo: row ? mapTodoRow(row) : null,
});
}),
);
router.get(
'/todos/:id',
asyncHandler(async (req, res) => {
const id = normalizeTodoId(req.params.id);
if (!id) {
res.status(400).json({ success: false, error: 'Invalid todo id' });
return;
}
const todo = await requireTodo(db, id);
if (!todo) {
res.status(404).json({ success: false, error: 'Todo not found' });
return;
}
res.status(200).json({ success: true, todo });
}),
);
router.put(
'/todos/:id',
asyncHandler(async (req, res) => {
const id = normalizeTodoId(req.params.id);
if (!id) {
res.status(400).json({ success: false, error: 'Invalid todo id' });
return;
}
const body = (req.body ?? {}) as Record<string, unknown>;
const updates: string[] = [];
const attachmentUpdate = parseAttachmentUpdate(body);
if (Object.prototype.hasOwnProperty.call(body, 'title')) {
const title = readString(body.title)?.trim() || '';
if (!title) {
res.status(400).json({ success: false, error: 'title is required' });
return;
}
updates.push(`title = ${sqlValue(title)}`);
}
if (Object.prototype.hasOwnProperty.call(body, 'description')) {
const description = body.description == null ? null : readString(body.description);
if (body.description != null && description == null) {
res.status(400).json({ success: false, error: 'description must be a string or null' });
return;
}
updates.push(`description = ${sqlValue(description)}`);
}
if (Object.prototype.hasOwnProperty.call(body, 'completed')) {
if (typeof body.completed !== 'boolean') {
res.status(400).json({ success: false, error: 'completed must be a boolean' });
return;
}
updates.push(`completed = ${sqlValue(body.completed)}`);
}
applyAttachmentUpdate(attachmentUpdate, updates);
if (updates.length === 0) {
res.status(400).json({ success: false, error: 'No updatable fields provided' });
return;
}
updates.push('updated_at = CURRENT_TIMESTAMP');
const row = await db.first(
`UPDATE todos SET ${updates.join(', ')} WHERE id = ${id} RETURNING ${TODO_SELECT_COLUMNS}`,
);
if (!row) {
res.status(404).json({ success: false, error: 'Todo not found' });
return;
}
res.status(200).json({ success: true, todo: mapTodoRow(row) });
}),
);
router.delete(
'/todos/:id',
asyncHandler(async (req, res) => {
const id = normalizeTodoId(req.params.id);
if (!id) {
res.status(400).json({ success: false, error: 'Invalid todo id' });
return;
}
const row = await db.first(`DELETE FROM todos WHERE id = ${id} RETURNING id`);
if (!row) {
res.status(404).json({ success: false, error: 'Todo not found' });
return;
}
res.status(200).json({ success: true, deletedId: id });
}),
);
router.post(
'/todos/:id/attachment/presign',
asyncHandler(async (req, res) => {
const id = normalizeTodoId(req.params.id);
if (!id) {
res.status(400).json({ success: false, error: 'Invalid todo id' });
return;
}
const todo = await requireTodo(db, id);
if (!todo) {
res.status(404).json({ success: false, error: 'Todo not found' });
return;
}
const filename = readString(req.body?.filename)?.trim() || '';
const contentType = readString(req.body?.contentType)?.trim() || undefined;
if (!filename) {
res.status(400).json({ success: false, error: 'filename is required' });
return;
}
const upload = presignTodoAttachmentUpload(config, id, filename, contentType);
res.status(200).json({
success: true,
...upload,
});
}),
);
router.post(
'/todos/:id/attachment/complete',
asyncHandler(async (req, res) => {
const id = normalizeTodoId(req.params.id);
if (!id) {
res.status(400).json({ success: false, error: 'Invalid todo id' });
return;
}
const key = readString(req.body?.key)?.trim() || '';
const filename = readString(req.body?.filename)?.trim() || '';
const attachmentUrl = readString(req.body?.attachmentUrl)?.trim() || '';
const contentType = readString(req.body?.contentType)?.trim() || null;
const rawSizeBytes = req.body?.sizeBytes;
const sizeBytes = rawSizeBytes == null ? null : Number(rawSizeBytes);
if (!key || !filename || !attachmentUrl) {
res.status(400).json({
success: false,
error: 'key, filename, and attachmentUrl are required',
});
return;
}
if (sizeBytes != null && (!Number.isFinite(sizeBytes) || sizeBytes < 0)) {
res.status(400).json({ success: false, error: 'sizeBytes must be a non-negative number' });
return;
}
const row = await db.first(
`
UPDATE todos
SET
attachment_key = ${sqlValue(key)},
attachment_filename = ${sqlValue(filename)},
attachment_content_type = ${sqlValue(contentType)},
attachment_size_bytes = ${sqlValue(sizeBytes)},
attachment_url = ${sqlValue(attachmentUrl)},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${id}
RETURNING ${TODO_SELECT_COLUMNS}
`,
);
if (!row) {
res.status(404).json({ success: false, error: 'Todo not found' });
return;
}
res.status(200).json({ success: true, todo: mapTodoRow(row) });
}),
);
return router;
}

25
backend/src/types/todo.ts Normal file
View File

@@ -0,0 +1,25 @@
export type TodoAttachment = {
key: string;
filename: string;
contentType: string | null;
sizeBytes: number | null;
url: string;
};
export type Todo = {
id: number;
title: string;
description: string | null;
completed: boolean;
createdAt: string;
updatedAt: string;
attachment: TodoAttachment | null;
};
export type PresignedUpload = {
url: string;
method: 'PUT';
headers: Record<string, string>;
key: string;
attachmentUrl: string;
};

18
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": [
"node"
],
"outDir": "dist"
},
"include": [
"src/**/*.ts"
]
}