Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/web-poc/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Dependencies
node_modules/

# Next.js
.next/
out/

# Environment
.env*.local

# Database
*.db
*.db-*

# Generated
lib/generated/
218 changes: 218 additions & 0 deletions apps/web-poc/app/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
"use server";

import { getClient, syncWithTurso } from "@/lib/db";

export interface Task {
id: string;
title: string;
description: string | null;
column_id: string;
position: number;
created_by: string;
assigned_to: string | null;
created_at: number;
updated_at: number;
archived: number;
}

export interface ColumnData {
id: string;
name: string;
position: number;
wip_limit: number | null;
tasks: Task[];
}

export async function getBoardData(boardId: string): Promise<{
board: { id: string; name: string } | null;
columns: ColumnData[];
tasks: Task[];
}> {
await syncWithTurso();
const client = getClient();

try {
const boardResult = await client.execute({
sql: "SELECT id, name FROM boards WHERE id = ?",
args: [boardId],
});

if (boardResult.rows.length === 0) {
return { board: null, columns: [], tasks: [] };
}

const board = {
id: String(boardResult.rows[0].id),
name: String(boardResult.rows[0].name),
};

const columnsResult = await client.execute({
sql: "SELECT id, name, position, wip_limit FROM columns WHERE board_id = ? ORDER BY position ASC",
args: [boardId],
});

const columns: ColumnData[] = columnsResult.rows.map((row) => ({
id: String(row.id),
name: String(row.name),
position: Number(row.position),
wip_limit: row.wip_limit ? Number(row.wip_limit) : null,
tasks: [],
}));

const columnIds = columns.map((c) => c.id);

let tasks: Task[] = [];
if (columnIds.length > 0) {
const placeholders = columnIds.map(() => "?").join(",");
const tasksResult = await client.execute({
sql: `SELECT id, title, description, column_id, position, created_by, assigned_to, created_at, updated_at, archived
FROM tasks
WHERE column_id IN (${placeholders}) AND archived = 0
ORDER BY position ASC`,
args: columnIds,
Comment on lines +66 to +72
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SQL injection vulnerability: The placeholders variable is concatenated directly into the SQL string without proper parameterization. While columnIds comes from the database, constructing SQL with string interpolation for IN clauses is dangerous. Use proper parameterized queries. libsql/Turso should support binding arrays for IN clauses, or construct the query differently to avoid this pattern.

Copilot uses AI. Check for mistakes.
});

tasks = tasksResult.rows.map((row) => ({
id: String(row.id),
title: String(row.title),
description: row.description ? String(row.description) : null,
column_id: String(row.column_id),
position: Number(row.position),
created_by: String(row.created_by),
assigned_to: row.assigned_to ? String(row.assigned_to) : null,
created_at: Number(row.created_at),
updated_at: Number(row.updated_at),
archived: Number(row.archived),
}));
}

const columnsWithTasks = columns.map((col) => ({
...col,
tasks: tasks.filter((t) => t.column_id === col.id),
}));

return {
board,
columns: columnsWithTasks,
tasks,
};
} catch (error) {
console.error("Error fetching board:", error);
return { board: null, columns: [], tasks: [] };
}
}

export async function moveTask(
taskId: string,
newColumnId: string,
newPosition: number
): Promise<Task | null> {
const client = getClient();
const now = Math.floor(Date.now() / 1000);

try {
await client.execute({
sql: "UPDATE tasks SET column_id = ?, position = ?, updated_at = ? WHERE id = ?",
args: [newColumnId, newPosition, now, taskId],
});

await syncWithTurso();

const result = await client.execute({
sql: "SELECT id, title, description, column_id, position, created_by, assigned_to, created_at, updated_at, archived FROM tasks WHERE id = ?",
args: [taskId],
});

if (result.rows.length === 0) {
return null;
}

const row = result.rows[0];
return {
id: String(row.id),
title: String(row.title),
description: row.description ? String(row.description) : null,
column_id: String(row.column_id),
position: Number(row.position),
created_by: String(row.created_by),
assigned_to: row.assigned_to ? String(row.assigned_to) : null,
created_at: Number(row.created_at),
updated_at: Number(row.updated_at),
archived: Number(row.archived),
};
} catch (error) {
console.error("Failed to move task:", error);
return null;
}
}

export async function addTask(data: {
title: string;
description?: string;
column_id: string;
created_by: string;
}): Promise<Task | null> {
const client = getClient();
const now = Math.floor(Date.now() / 1000);
const id = crypto.randomUUID();
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using crypto.randomUUID() instead of ulid() for task IDs is inconsistent with the rest of the codebase. The core package uses ulid() for generating IDs (see packages/core/src/services/task.ts). This inconsistency could cause issues if the system expects ULID format for sorting by creation time or other ULID-specific features.

Copilot uses AI. Check for mistakes.

try {
await client.execute({
sql: `INSERT INTO tasks (id, title, description, column_id, position, created_by, assigned_to, created_at, updated_at, archived, version, depends_on, files, labels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
id,
data.title,
data.description || null,
data.column_id,
0,
data.created_by,
null,
now,
now,
0,
1,
"[]",
"[]",
"[]",
],
});

await syncWithTurso();

return {
id,
title: data.title,
description: data.description || null,
column_id: data.column_id,
position: 0,
created_by: data.created_by,
assigned_to: null,
created_at: now,
updated_at: now,
archived: 0,
};
} catch (error) {
console.error("Failed to add task:", error);
return null;
}
}

export async function archiveTask(taskId: string): Promise<boolean> {
const client = getClient();
const now = Math.floor(Date.now() / 1000);

try {
await client.execute({
sql: "UPDATE tasks SET archived = 1, archived_at = ?, updated_at = ? WHERE id = ?",
args: [now, now, taskId],
});

await syncWithTurso();

return true;
} catch (error) {
console.error("Failed to archive task:", error);
return false;
}
}
Comment on lines +26 to +218
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server actions (getBoardData, moveTask, addTask, archiveTask) lack input validation. For example, boardId, taskId, newColumnId, and other user-provided inputs are passed directly to SQL queries without validation. While parameterized queries prevent SQL injection, there should still be validation for input format (e.g., ULID format), length constraints, and business logic constraints to prevent invalid data from being processed.

Copilot uses AI. Check for mistakes.
63 changes: 63 additions & 0 deletions apps/web-poc/app/api/boards/[id]/tasks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@libsql/client';

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: boardId } = await params;

try {
// Connect to local Turso embedded replica
const client = createClient({
url: process.env.TURSO_DATABASE_URL || 'file:./local.db',
syncUrl: process.env.TURSO_SYNC_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
Comment on lines +12 to +16
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API route creates a new database client on every request but never closes it (lines 12-16). This will cause connection leaks. In a serverless environment like Cloudflare Workers, each request should properly clean up its resources. Add client.close() in a finally block or use proper connection management.

Copilot uses AI. Check for mistakes.

// Sync with remote before reading
await client.sync();
Comment on lines +18 to +19
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sync() call on line 19 may fail or not be supported depending on the client configuration. If syncUrl is not provided, client.sync() might throw an error or be undefined. Add proper error handling or check if sync is available before calling it, similar to how it's done in turso-sync.ts (line 35-41).

Suggested change
// Sync with remote before reading
await client.sync();
// Sync with remote before reading, if supported/configured
if (process.env.TURSO_SYNC_URL && typeof (client as any).sync === 'function') {
try {
await client.sync();
} catch (syncError) {
console.warn('Turso sync failed, continuing with local replica:', syncError);
}
}

Copilot uses AI. Check for mistakes.

// Fetch tasks from the database
const result = await client.execute({
sql: `
SELECT
id,
title,
description,
column_id as columnId,
priority,
due_date as dueDate,
created_at as createdAt
FROM tasks
WHERE archived = 0
ORDER BY position ASC, created_at DESC
`,
args: [],
Comment on lines +34 to +36
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The boardId parameter from the URL is not used in the SQL query (line 8, 32-34). The query fetches ALL non-archived tasks from the database regardless of which board they belong to. This is a data leak - users can access tasks from any board by changing the boardId in the URL. Add a WHERE clause to filter by board_id or join with the columns table to ensure only tasks belonging to the requested board are returned.

Suggested change
ORDER BY position ASC, created_at DESC
`,
args: [],
AND board_id = ?
ORDER BY position ASC, created_at DESC
`,
args: [boardId],

Copilot uses AI. Check for mistakes.
});

const tasks = result.rows.map((row) => ({
id: row.id as string,
title: row.title as string,
description: (row.description as string) || '',
columnId: row.columnId as string,
priority: (row.priority as 'high' | 'medium' | 'low') || 'medium',
dueDate: row.dueDate
? new Date(row.dueDate as string).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0],
}));

return NextResponse.json({
boardId,
tasks,
count: tasks.length
});

} catch (error) {
console.error('Error fetching tasks:', error);
return NextResponse.json(
{ error: 'Failed to fetch tasks', details: String(error) },
{ status: 500 }
);
}
}
Loading
Loading