New API + API Token Updates

This commit is contained in:
2026-01-16 13:20:11 -07:00
parent 756ab2a38f
commit 4412229990
26 changed files with 1661 additions and 1012 deletions

View File

@@ -1,44 +1,67 @@
import type { APIRoute } from 'astro';
import { db } from '../../../../db';
import { categories, members, timeEntries } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { categories, members, timeEntries } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ locals, redirect, params }) => {
export const POST: APIRoute = async ({ request, locals, redirect, params }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
return new Response("Unauthorized", { status: 401 });
}
const { id } = params;
let redirectTo: string | undefined;
const userOrg = await db.select()
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
redirectTo = body.redirectTo;
} else {
const formData = await request.formData();
redirectTo = formData.get("redirectTo")?.toString();
}
const userOrg = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response('No organization found', { status: 400 });
return new Response("No organization found", { status: 400 });
}
const isAdmin = userOrg.role === 'owner' || userOrg.role === 'admin';
const isAdmin = userOrg.role === "owner" || userOrg.role === "admin";
if (!isAdmin) {
return new Response('Forbidden', { status: 403 });
return new Response("Forbidden", { status: 403 });
}
const hasEntries = await db.select()
const hasEntries = await db
.select()
.from(timeEntries)
.where(eq(timeEntries.categoryId, id!))
.get();
if (hasEntries) {
return new Response('Cannot delete category with time entries', { status: 400 });
return new Response("Cannot delete category with time entries", {
status: 400,
});
}
await db.delete(categories)
.where(and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId)
));
await db
.delete(categories)
.where(
and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId),
),
);
return redirect('/dashboard/team/settings');
if (locals.scopes) {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return redirect(redirectTo || "/dashboard/team/settings");
};

View File

@@ -1,46 +1,72 @@
import type { APIRoute } from 'astro';
import { db } from '../../../../db';
import { categories, members } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { categories, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ request, locals, redirect, params }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
return new Response("Unauthorized", { status: 401 });
}
const { id } = params;
const formData = await request.formData();
const name = formData.get('name')?.toString();
const color = formData.get('color')?.toString();
let name: string | undefined;
let color: string | undefined;
let redirectTo: string | undefined;
if (!name) {
return new Response('Name is required', { status: 400 });
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
color = body.color;
redirectTo = body.redirectTo;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
color = formData.get("color")?.toString();
redirectTo = formData.get("redirectTo")?.toString();
}
const userOrg = await db.select()
if (!name) {
return new Response("Name is required", { status: 400 });
}
const userOrg = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response('No organization found', { status: 400 });
return new Response("No organization found", { status: 400 });
}
const isAdmin = userOrg.role === 'owner' || userOrg.role === 'admin';
const isAdmin = userOrg.role === "owner" || userOrg.role === "admin";
if (!isAdmin) {
return new Response('Forbidden', { status: 403 });
return new Response("Forbidden", { status: 403 });
}
await db.update(categories)
await db
.update(categories)
.set({
name,
color: color || null,
})
.where(and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId)
));
.where(
and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId),
),
);
return redirect('/dashboard/team/settings');
if (locals.scopes) {
return new Response(
JSON.stringify({ success: true, id, name, color: color || null }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
return redirect(redirectTo || "/dashboard/team/settings");
};

View File

@@ -1,38 +1,59 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { categories, members } from '../../../db/schema';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { categories, members } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
return new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
const color = formData.get('color')?.toString();
let name: string | undefined;
let color: string | undefined;
let redirectTo: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
color = body.color;
redirectTo = body.redirectTo;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
color = formData.get("color")?.toString();
redirectTo = formData.get("redirectTo")?.toString();
}
if (!name) {
return new Response('Name is required', { status: 400 });
return new Response("Name is required", { status: 400 });
}
const userOrg = await db.select()
const userOrg = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response('No organization found', { status: 400 });
return new Response("No organization found", { status: 400 });
}
const id = nanoid();
await db.insert(categories).values({
id: nanoid(),
id,
organizationId: userOrg.organizationId,
name,
color: color || null,
});
return redirect('/dashboard/team/settings');
if (locals.scopes) {
return new Response(JSON.stringify({ id, name, color: color || null }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}
return redirect(redirectTo || "/dashboard/team/settings");
};

View File

@@ -1,71 +1,100 @@
import type { APIRoute } from 'astro';
import { db } from '../../../../db';
import { clients, members, timeEntries, timeEntryTags } from '../../../../db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import {
clients,
members,
timeEntries,
timeEntryTags,
} from "../../../../db/schema";
import { eq, and, inArray } from "drizzle-orm";
export const POST: APIRoute = async ({ params, locals, redirect }) => {
const user = locals.user;
if (!user) {
return redirect('/login');
return redirect("/login");
}
const { id } = params;
if (!id) {
return new Response('Client ID is required', { status: 400 });
return new Response("Client ID is required", { status: 400 });
}
try {
// Get the client to check organization ownership
const client = await db.select()
const client = await db
.select()
.from(clients)
.where(eq(clients.id, id))
.get();
if (!client) {
return new Response('Client not found', { status: 404 });
if (locals.scopes) {
return new Response(JSON.stringify({ error: "Client not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response("Client not found", { status: 404 });
}
// Verify user is a member of the organization
const membership = await db.select()
const membership = await db
.select()
.from(members)
.where(and(
eq(members.userId, user.id),
eq(members.organizationId, client.organizationId)
))
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, client.organizationId),
),
)
.get();
if (!membership) {
return new Response('Not authorized', { status: 403 });
if (locals.scopes) {
return new Response(JSON.stringify({ error: "Not authorized" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
return new Response("Not authorized", { status: 403 });
}
// Find all time entries for this client to clean up tags
const clientEntries = await db.select({ id: timeEntries.id })
const clientEntries = await db
.select({ id: timeEntries.id })
.from(timeEntries)
.where(eq(timeEntries.clientId, id))
.all();
const entryIds = clientEntries.map(e => e.id);
const entryIds = clientEntries.map((e) => e.id);
if (entryIds.length > 0) {
// Delete tags associated with these entries
await db.delete(timeEntryTags)
await db
.delete(timeEntryTags)
.where(inArray(timeEntryTags.timeEntryId, entryIds))
.run();
// Delete the time entries
await db.delete(timeEntries)
.where(eq(timeEntries.clientId, id))
.run();
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
}
// Delete the client
await db.delete(clients)
.where(eq(clients.id, id))
.run();
await db.delete(clients).where(eq(clients.id, id)).run();
return redirect('/dashboard/clients');
if (locals.scopes) {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return redirect("/dashboard/clients");
} catch (error) {
console.error('Error deleting client:', error);
return new Response('Failed to delete client', { status: 500 });
console.error("Error deleting client:", error);
if (locals.scopes) {
return new Response(
JSON.stringify({ error: "Failed to delete client" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response("Failed to delete client", { status: 500 });
}
};

View File

@@ -14,16 +14,24 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
return new Response("Client ID is required", { status: 400 });
}
const formData = await request.formData();
const name = formData.get("name") as string;
const email = formData.get("email") as string;
let name: string | undefined;
let email: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
email = body.email;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
email = formData.get("email")?.toString();
}
if (!name || name.trim().length === 0) {
return new Response("Client name is required", { status: 400 });
}
try {
// Get the client to check organization ownership
const client = await db
.select()
.from(clients)
@@ -31,10 +39,15 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
.get();
if (!client) {
if (locals.scopes) {
return new Response(JSON.stringify({ error: "Client not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response("Client not found", { status: 404 });
}
// Verify user is a member of the organization
const membership = await db
.select()
.from(members)
@@ -47,10 +60,15 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
.get();
if (!membership) {
if (locals.scopes) {
return new Response(JSON.stringify({ error: "Not authorized" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
return new Response("Not authorized", { status: 403 });
}
// Update client
await db
.update(clients)
.set({
@@ -60,9 +78,33 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
.where(eq(clients.id, id))
.run();
if (locals.scopes) {
return new Response(
JSON.stringify({
success: true,
id,
name: name.trim(),
email: email?.trim() || null,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
return redirect(`/dashboard/clients/${id}`);
} catch (error) {
console.error("Error updating client:", error);
if (locals.scopes) {
return new Response(
JSON.stringify({ error: "Failed to update client" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response("Failed to update client", { status: 500 });
}
};

View File

@@ -1,38 +1,57 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { clients, members } from '../../../db/schema';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { clients, members } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
return new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
const email = formData.get('email')?.toString();
let name: string | undefined;
let email: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
email = body.email;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
email = formData.get("email")?.toString();
}
if (!name) {
return new Response('Name is required', { status: 400 });
return new Response("Name is required", { status: 400 });
}
const userOrg = await db.select()
const userOrg = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response('No organization found', { status: 400 });
return new Response("No organization found", { status: 400 });
}
const id = nanoid();
await db.insert(clients).values({
id: nanoid(),
id,
organizationId: userOrg.organizationId,
name,
email: email || null,
});
return redirect('/dashboard/clients');
if (locals.scopes) {
return new Response(JSON.stringify({ id, name, email: email || null }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}
return redirect("/dashboard/clients");
};

View File

@@ -1,19 +1,26 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { organizations, members } from '../../../db/schema';
import { nanoid } from 'nanoid';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { organizations, members } from "../../../db/schema";
import { nanoid } from "nanoid";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
return new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
let name: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
}
if (!name) {
return new Response('Name is required', { status: 400 });
return new Response("Name is required", { status: 400 });
}
const orgId = nanoid();
@@ -25,8 +32,15 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
await db.insert(members).values({
userId: user.id,
organizationId: orgId,
role: 'owner',
role: "owner",
});
return redirect('/dashboard');
if (locals.scopes) {
return new Response(JSON.stringify({ id: orgId, name }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}
return redirect("/dashboard");
};

View File

@@ -1,34 +1,43 @@
import type { APIRoute } from 'astro';
import { db } from '../../../../db';
import { timeEntries } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { timeEntries } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ params, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
});
}
const entryId = params.id;
if (!entryId) {
return new Response(JSON.stringify({ error: 'Entry ID required' }), { status: 400 });
return new Response(JSON.stringify({ error: "Entry ID required" }), {
status: 400,
});
}
const entry = await db.select()
const entry = await db
.select()
.from(timeEntries)
.where(and(
eq(timeEntries.id, entryId),
eq(timeEntries.userId, user.id)
))
.where(and(eq(timeEntries.id, entryId), eq(timeEntries.userId, user.id)))
.get();
if (!entry) {
return new Response(JSON.stringify({ error: 'Entry not found' }), { status: 404 });
return new Response(JSON.stringify({ error: "Entry not found" }), {
status: 404,
});
}
await db.delete(timeEntries)
.where(eq(timeEntries.id, entryId))
.run();
await db.delete(timeEntries).where(eq(timeEntries.id, entryId)).run();
return redirect('/dashboard/tracker');
if (locals.scopes) {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return redirect("/dashboard/tracker");
};

View File

@@ -0,0 +1,40 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { apiTokens } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const DELETE: APIRoute = async ({ params, locals }) => {
const user = locals.user;
if (!user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { id } = params;
if (!id) {
return new Response(JSON.stringify({ error: "Token ID is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const result = await db
.delete(apiTokens)
.where(and(eq(apiTokens.id, id), eq(apiTokens.userId, user.id)))
.returning();
if (result.length === 0) {
return new Response(JSON.stringify({ error: "Token not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};

View File

@@ -0,0 +1,49 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { apiTokens } from "../../../../db/schema";
import { generateApiToken, hashToken } from "../../../../lib/api-auth";
export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const formData = await request.formData();
const name = formData.get("name")?.toString();
if (!name) {
return new Response(JSON.stringify({ error: "Name is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const rawToken = generateApiToken();
const hashedToken = hashToken(rawToken);
const [newToken] = await db
.insert(apiTokens)
.values({
userId: user.id,
name,
token: hashedToken,
})
.returning();
return new Response(
JSON.stringify({
...newToken,
token: rawToken,
}),
{
status: 201,
headers: {
"Content-Type": "application/json",
},
},
);
};