AI Update: Started backend platform server on port 8080
This commit is contained in:
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;
|
||||
};
|
||||
Reference in New Issue
Block a user