Skip to content
Open
67 changes: 66 additions & 1 deletion src/app/api/goals/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,75 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";
import { dispatchToAllWebhooks } from "@/lib/webhooks";

export const dynamic = "force-dynamic";

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

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

const body = await req.json().catch(() => ({}));
const { current } = body;

if (typeof current !== "number" || current < 0) {
return Response.json(
{ error: "Invalid current value" },
{ status: 400 }
);
}

const { data: existingGoal } = await supabaseAdmin
.from("goals")
.select("*")
.eq("id", params.id)
.eq("user_id", user.id)
.single();

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

const wasCompleted = existingGoal.current >= existingGoal.target;
const { data: updatedGoal, error } = await supabaseAdmin
.from("goals")
.update({ current })
.eq("id", params.id)
.eq("user_id", user.id)
.select()
.single();

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

const isNowCompleted = updatedGoal.current >= updatedGoal.target;

if (!wasCompleted && isNowCompleted) {
dispatchToAllWebhooks(user.id, "goal.completed", {
goalId: updatedGoal.id,
title: updatedGoal.title,
target: updatedGoal.target,
unit: updatedGoal.unit,
recurrence: updatedGoal.recurrence,
completedAt: new Date().toISOString(),
}).catch(() => {});
}

return Response.json({ goal: updatedGoal });
}

export async function DELETE(
_req: Request,
{ params }: { params: { id: string } }
Expand All @@ -17,7 +83,6 @@ export async function DELETE(
const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

// Only delete if the goal belongs to the authenticated user
const { error } = await supabaseAdmin
.from("goals")
.delete()
Expand Down
11 changes: 10 additions & 1 deletion src/app/api/goals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";
import { dispatchToAllWebhooks } from "@/lib/webhooks";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -141,5 +142,13 @@ export async function POST(req: Request) {

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

dispatchToAllWebhooks(user.id, "goal.created", {
goalId: goal.id,
title: goal.title,
target: goal.target,
unit: goal.unit,
recurrence: goal.recurrence,
}).catch(() => {});

return Response.json({ goal }, { status: 201 });
}
}
153 changes: 153 additions & 0 deletions src/app/api/integrations/jira/credentials/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser, AppUser } from "@/lib/resolve-user";
import { encryptToken } from "@/lib/crypto";

export const dynamic = "force-dynamic";

interface JiraCredentialsInput {
jiraDomain: string;
email: string;
apiToken: string;
projectKey?: string;
}

function validateJiraDomain(domain: string): boolean {
const atlassianDomainRegex = /^[a-zA-Z0-9][-a-zA-Z0-9]*\.atlassian\.net$/;
return atlassianDomainRegex.test(domain);
}

function validateProjectKey(key: string): boolean {
const projectKeyRegex = /^[A-Z][A-Z0-9]{0,9}$/;
return projectKeyRegex.test(key);
}

async function testJiraConnection(
domain: string,
email: string,
token: string
): Promise<boolean> {
const auth = Buffer.from(`${email}:${token}`).toString("base64");
const response = await fetch(`https://${domain}/rest/api/3/myself`, {
headers: {
Authorization: `Basic ${auth}`,
Accept: "application/json",
},
cache: "no-store",
});
return response.ok;
}

async function requireUser(): Promise<{ user: AppUser } | { error: Response }> {
const session = await getServerSession(authOptions);

if (!session?.githubId || !session?.githubLogin) {
return { error: Response.json({ error: "Unauthorized" }, { status: 401 }) };
}

const userRow = await resolveAppUser(session.githubId, session.githubLogin);

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

return { user: userRow };
}

export async function GET(req: NextRequest) {
const result = await requireUser();
if ("error" in result) return result.error;

const { data: credentials } = await supabaseAdmin
.from("jira_credentials")
.select("id, jira_domain, email, project_key, is_active, created_at")
.eq("user_id", result.user.id);

return Response.json({ credentials: credentials || [] });
}

export async function POST(req: NextRequest) {
const result = await requireUser();
if ("error" in result) return result.error;

let body: JiraCredentialsInput;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}

const { jiraDomain, email, apiToken, projectKey } = body;

if (!jiraDomain || !email || !apiToken) {
return Response.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}

if (!validateJiraDomain(jiraDomain)) {
return Response.json(
{ error: "Invalid Jira domain format" },
{ status: 400 }
);
}

if (projectKey && !validateProjectKey(projectKey)) {
return Response.json(
{ error: "Invalid project key format (use uppercase letters and numbers, e.g. PROJ)" },
{ status: 400 }
);
}

const valid = await testJiraConnection(jiraDomain, email, apiToken);
if (!valid) {
return Response.json(
{ error: "Could not connect to Jira with provided credentials" },
{ status: 400 }
);
}

const { encrypted, iv } = encryptToken(apiToken);

await supabaseAdmin.from("jira_credentials").upsert(
{
user_id: result.user.id,
jira_domain: jiraDomain,
email,
api_token: encrypted,
token_iv: iv,
project_key: projectKey || null,
is_active: true,
updated_at: new Date().toISOString(),
},
{ onConflict: "user_id" }
);

return Response.json({ success: true });
}

export async function DELETE(req: NextRequest) {
const result = await requireUser();
if ("error" in result) return result.error;

const { searchParams } = new URL(req.url);
const credentialId = searchParams.get("id");

if (credentialId) {
await supabaseAdmin
.from("jira_credentials")
.delete()
.eq("id", credentialId)
.eq("user_id", result.user.id);
} else {
await supabaseAdmin
.from("jira_credentials")
.delete()
.eq("user_id", result.user.id);
}

return Response.json({ success: true });
}
Loading
Loading