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

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;
};