AI Update: Started backend platform server on port 8080
This commit is contained in:
3
backend/.dockerignore
Normal file
3
backend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.git
|
||||
.DS_Store
|
||||
13
backend/.env
Normal file
13
backend/.env
Normal 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
14
backend/.env.example
Normal 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
2
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal 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
59
backend/README.md
Normal 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
187
backend/bun.lock
Normal 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
22
backend/package.json
Normal 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
81
backend/src/config.ts
Normal 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
37
backend/src/index.ts
Normal 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
158
backend/src/lib/db.ts
Normal 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
139
backend/src/lib/storage.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
74
backend/src/lib/todos-schema.ts
Normal file
74
backend/src/lib/todos-schema.ts
Normal 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 ?? ''),
|
||||
};
|
||||
}
|
||||
24
backend/src/routes/health.ts
Normal file
24
backend/src/routes/health.ts
Normal 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
331
backend/src/routes/todos.ts
Normal 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
25
backend/src/types/todo.ts
Normal 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
18
backend/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user