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
37 changes: 37 additions & 0 deletions src/app/api/tasks/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";

export const dynamic = "force-dynamic";

export async function DELETE(
_req: Request,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const { data: user } = await supabaseAdmin
.from("users")
.select("id")
.eq("github_id", session.githubId)
.single();

if (!user) return Response.json({ error: "User not found" }, { status: 404 });

// Only delete if the task belongs to the authenticated user. Associated
// time_entries are removed via the ON DELETE CASCADE foreign key.
const { error } = await supabaseAdmin
.from("tasks")
.delete()
.eq("id", params.id)
.eq("user_id", user.id);

if (error) {
return Response.json({ error: "Failed to delete task" }, { status: 500 });
}

return Response.json({ success: true }, { status: 200 });
}
101 changes: 101 additions & 0 deletions src/app/api/tasks/[id]/time-entries/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";

export const dynamic = "force-dynamic";

async function getAuthenticatedUserAndTask(taskId: string) {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return { error: Response.json({ error: "Unauthorized" }, { status: 401 }) };
}

const { data: user } = await supabaseAdmin
.from("users")
.select("id")
.eq("github_id", session.githubId)
.single();

if (!user) {
return { error: Response.json({ error: "User not found" }, { status: 404 }) };
}

const { data: task } = await supabaseAdmin
.from("tasks")
.select("id")
.eq("id", taskId)
.eq("user_id", user.id)
.single();

if (!task) {
return { error: Response.json({ error: "Task not found" }, { status: 404 }) };
}

return { userId: user.id as string };
}

export async function GET(
_req: Request,
{ params }: { params: { id: string } }
) {
const result = await getAuthenticatedUserAndTask(params.id);
if (result.error) return result.error;

const { data: entries, error } = await supabaseAdmin
.from("time_entries")
.select("*")
.eq("task_id", params.id)
.eq("user_id", result.userId)
.order("started_at", { ascending: false });

if (error) {
return Response.json({ error: "Failed to load time entries" }, { status: 500 });
}

return Response.json({ entries: entries ?? [] });
}

export async function POST(
req: Request,
{ params }: { params: { id: string } }
) {
const result = await getAuthenticatedUserAndTask(params.id);
if (result.error) return result.error;

const body = (await req.json()) as { startedAt?: string; endedAt?: string };

if (!body.startedAt || !body.endedAt) {
return Response.json({ error: "startedAt and endedAt required" }, { status: 400 });
}

const startedAt = new Date(body.startedAt);
const endedAt = new Date(body.endedAt);

if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
return Response.json({ error: "Invalid date value" }, { status: 400 });
}

if (endedAt <= startedAt) {
return Response.json({ error: "endedAt must be after startedAt" }, { status: 400 });
}

// Duration is computed server-side from the timestamps, never trusted
// from the client, so a manipulated request can't inflate logged time.
const durationMinutes = Math.round((endedAt.getTime() - startedAt.getTime()) / 60000);

const { data: entry, error } = await supabaseAdmin
.from("time_entries")
.insert({
task_id: params.id,
user_id: result.userId,
started_at: startedAt.toISOString(),
ended_at: endedAt.toISOString(),
duration_minutes: durationMinutes,
})
.select()
.single();

if (error) return Response.json({ error: error.message }, { status: 500 });

return Response.json({ entry }, { status: 201 });
}
86 changes: 86 additions & 0 deletions src/app/api/tasks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";

export const dynamic = "force-dynamic";

export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const { data: user } = await supabaseAdmin
.from("users")
.select("id")
.eq("github_id", session.githubId)
.single();

if (!user) return Response.json({ error: "User not found" }, { status: 404 });

const { data: tasks, error: tasksError } = await supabaseAdmin
.from("tasks")
.select("*")
.eq("user_id", user.id)
.order("created_at", { ascending: false });

if (tasksError) {
return Response.json({ error: "Failed to load tasks" }, { status: 500 });
}

const { data: entries, error: entriesError } = await supabaseAdmin
.from("time_entries")
.select("task_id, duration_minutes")
.eq("user_id", user.id);

if (entriesError) {
return Response.json({ error: "Failed to load time entries" }, { status: 500 });
}

const totalsByTask = new Map<string, number>();
for (const entry of entries ?? []) {
const current = totalsByTask.get(entry.task_id) ?? 0;
totalsByTask.set(entry.task_id, current + entry.duration_minutes);
}

const tasksWithTotals = (tasks ?? []).map((task) => ({
...task,
totalMinutes: totalsByTask.get(task.id) ?? 0,
}));

return Response.json({ tasks: tasksWithTotals });
}

export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const body = (await req.json()) as { title?: string };

if (!body.title || !body.title.trim()) {
return Response.json({ error: "title required" }, { status: 400 });
}

const { data: user } = await supabaseAdmin
.from("users")
.select("id")
.eq("github_id", session.githubId)
.single();

if (!user) return Response.json({ error: "User not found" }, { status: 404 });

const { data: task, error } = await supabaseAdmin
.from("tasks")
.insert({
user_id: user.id,
title: body.title.trim(),
})
.select()
.single();

if (error) return Response.json({ error: error.message }, { status: 500 });

return Response.json({ task: { ...task, totalMinutes: 0 } }, { status: 201 });
}
37 changes: 37 additions & 0 deletions src/app/api/time-entries/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";

export const dynamic = "force-dynamic";

export async function DELETE(
_req: Request,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const { data: user } = await supabaseAdmin
.from("users")
.select("id")
.eq("github_id", session.githubId)
.single();

if (!user) return Response.json({ error: "User not found" }, { status: 404 });

// Only delete if the entry belongs to the authenticated user, so a
// session ID from another account can't be used to delete their logs.
const { error } = await supabaseAdmin
.from("time_entries")
.delete()
.eq("id", params.id)
.eq("user_id", user.id);

if (error) {
return Response.json({ error: "Failed to delete time entry" }, { status: 500 });
}

return Response.json({ success: true }, { status: 200 });
}
56 changes: 56 additions & 0 deletions src/app/api/time-entries/summary/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";

export const dynamic = "force-dynamic";

const DAYS_IN_SUMMARY = 7;

function dateKey(iso: string): string {
return iso.slice(0, 10); // "YYYY-MM-DD" in UTC
}

export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const { data: user } = await supabaseAdmin
.from("users")
.select("id")
.eq("github_id", session.githubId)
.single();

if (!user) return Response.json({ error: "User not found" }, { status: 404 });

const since = new Date();
since.setUTCDate(since.getUTCDate() - (DAYS_IN_SUMMARY - 1));
since.setUTCHours(0, 0, 0, 0);

const { data: entries, error } = await supabaseAdmin
.from("time_entries")
.select("started_at, duration_minutes")
.eq("user_id", user.id)
.gte("started_at", since.toISOString());

if (error) {
return Response.json({ error: "Failed to load time entries" }, { status: 500 });
}

const totalsByDay = new Map<string, number>();
for (const entry of entries ?? []) {
const key = dateKey(entry.started_at);
totalsByDay.set(key, (totalsByDay.get(key) ?? 0) + entry.duration_minutes);
}

const days: { date: string; totalMinutes: number }[] = [];
for (let i = DAYS_IN_SUMMARY - 1; i >= 0; i -= 1) {
const d = new Date();
d.setUTCDate(d.getUTCDate() - i);
const key = d.toISOString().slice(0, 10);
days.push({ date: key, totalMinutes: totalsByDay.get(key) ?? 0 });
}

return Response.json({ days });
}
Loading
Loading