First pass

This commit is contained in:
2025-12-25 22:10:06 -07:00
parent a2af6195f9
commit 455c3dbd9a
58 changed files with 10299 additions and 3 deletions

View File

@@ -0,0 +1,40 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { siteSettings } 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 || !user.isSiteAdmin) {
return new Response('Unauthorized', { status: 403 });
}
const formData = await request.formData();
const registrationEnabled = formData.get('registration_enabled') === 'on';
// Check if setting exists
const existingSetting = await db.select()
.from(siteSettings)
.where(eq(siteSettings.key, 'registration_enabled'))
.get();
if (existingSetting) {
// Update
await db.update(siteSettings)
.set({
value: registrationEnabled ? 'true' : 'false',
updatedAt: new Date()
})
.where(eq(siteSettings.key, 'registration_enabled'));
} else {
// Insert
await db.insert(siteSettings).values({
id: nanoid(),
key: 'registration_enabled',
value: registrationEnabled ? 'true' : 'false',
});
}
return redirect('/admin');
};

View File

@@ -0,0 +1,33 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users } from '../../../db/schema';
import { verifyPassword, createSession } from '../../../lib/auth';
import { eq } from 'drizzle-orm';
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
const formData = await request.formData();
const email = formData.get('email')?.toString();
const password = formData.get('password')?.toString();
if (!email || !password) {
return new Response('Missing fields', { status: 400 });
}
const user = await db.select().from(users).where(eq(users.email, email)).get();
if (!user || !(await verifyPassword(password, user.passwordHash))) {
return new Response('Invalid email or password', { status: 400 });
}
const { sessionId, expiresAt } = await createSession(user.id);
cookies.set('session_id', sessionId, {
path: '/',
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: 'lax',
expires: expiresAt,
});
return redirect('/dashboard');
};

View File

@@ -0,0 +1,11 @@
import type { APIRoute } from 'astro';
import { invalidateSession } from '../../../lib/auth';
export const POST: APIRoute = async ({ cookies, redirect }) => {
const sessionId = cookies.get('session_id')?.value;
if (sessionId) {
await invalidateSession(sessionId);
cookies.delete('session_id', { path: '/' });
}
return redirect('/login');
};

View File

@@ -0,0 +1,80 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users, organizations, members, siteSettings } from '../../../db/schema';
import { hashPassword, createSession } from '../../../lib/auth';
import { eq, count, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
// Check if this is the first user
const userCountResult = await db.select({ count: count() }).from(users).get();
const isFirstUser = userCountResult ? userCountResult.count === 0 : true;
// If not first user, check if registration is enabled
if (!isFirstUser) {
const registrationSetting = await db.select()
.from(siteSettings)
.where(eq(siteSettings.key, 'registration_enabled'))
.get();
const registrationEnabled = registrationSetting?.value === 'true';
if (!registrationEnabled) {
return new Response('Registration is currently disabled', { status: 403 });
}
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
const email = formData.get('email')?.toString();
const password = formData.get('password')?.toString();
if (!name || !email || !password) {
return new Response('Missing fields', { status: 400 });
}
// Check if user exists
const existingUser = await db.select().from(users).where(eq(users.email, email)).get();
if (existingUser) {
return new Response('User already exists', { status: 400 });
}
const passwordHash = await hashPassword(password);
const userId = nanoid();
// Create user
await db.insert(users).values({
id: userId,
name,
email,
passwordHash,
isSiteAdmin: isFirstUser,
});
// Create default organization
const orgId = nanoid();
await db.insert(organizations).values({
id: orgId,
name: `${name}'s Organization`,
});
// Add user to organization
await db.insert(members).values({
userId,
organizationId: orgId,
role: 'owner',
});
// Create session
const { sessionId, expiresAt } = await createSession(userId);
cookies.set('session_id', sessionId, {
path: '/',
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: 'lax',
expires: expiresAt,
});
return redirect('/dashboard');
};

View File

@@ -0,0 +1,47 @@
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 }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const { id } = params;
// Get user's organization
const userOrg = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response('No organization found', { status: 400 });
}
const isAdmin = userOrg.role === 'owner' || userOrg.role === 'admin';
if (!isAdmin) {
return new Response('Forbidden', { status: 403 });
}
// Check if category has time entries
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 });
}
// Delete category
await db.delete(categories)
.where(and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId)
));
return redirect('/dashboard/team/settings');
};

View File

@@ -0,0 +1,48 @@
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 });
}
const { id } = params;
const formData = await request.formData();
const name = formData.get('name')?.toString();
const color = formData.get('color')?.toString();
if (!name) {
return new Response('Name is required', { status: 400 });
}
// Get user's organization
const userOrg = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response('No organization found', { status: 400 });
}
const isAdmin = userOrg.role === 'owner' || userOrg.role === 'admin';
if (!isAdmin) {
return new Response('Forbidden', { status: 403 });
}
// Update category
await db.update(categories)
.set({
name,
color: color || null,
})
.where(and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId)
));
return redirect('/dashboard/team/settings');
};

View File

@@ -0,0 +1,40 @@
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 });
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
const color = formData.get('color')?.toString();
if (!name) {
return new Response('Name is required', { status: 400 });
}
// Get user's first organization
const userOrg = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response('No organization found', { status: 400 });
}
// Create category
await db.insert(categories).values({
id: nanoid(),
organizationId: userOrg.organizationId,
name,
color: color || null,
});
return redirect('/dashboard/team/settings');
};

View File

@@ -0,0 +1,40 @@
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 });
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
const email = formData.get('email')?.toString();
if (!name) {
return new Response('Name is required', { status: 400 });
}
// Get user's first organization
const userOrg = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response('No organization found', { status: 400 });
}
// Create client
await db.insert(clients).values({
id: nanoid(),
organizationId: userOrg.organizationId,
name,
email: email || null,
});
return redirect('/dashboard/clients');
};

View File

@@ -0,0 +1,34 @@
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 });
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
if (!name) {
return new Response('Name is required', { status: 400 });
}
// Create organization
const orgId = nanoid();
await db.insert(organizations).values({
id: orgId,
name,
});
// Add user as owner
await db.insert(members).values({
userId: user.id,
organizationId: orgId,
role: 'owner',
});
return redirect('/dashboard');
};

View File

@@ -0,0 +1,60 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { members } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// Check if user is admin
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userMembership || (userMembership.role !== 'owner' && userMembership.role !== 'admin')) {
return new Response('Unauthorized', { status: 403 });
}
const formData = await request.formData();
const targetUserId = formData.get('userId')?.toString();
const newRole = formData.get('role')?.toString();
if (!targetUserId || !newRole) {
return new Response('Missing parameters', { status: 400 });
}
if (!['member', 'admin'].includes(newRole)) {
return new Response('Invalid role', { status: 400 });
}
// Can't change owner's role
const targetMember = await db.select()
.from(members)
.where(and(
eq(members.userId, targetUserId),
eq(members.organizationId, userMembership.organizationId)
))
.get();
if (!targetMember) {
return new Response('Member not found', { status: 404 });
}
if (targetMember.role === 'owner') {
return new Response('Cannot change owner role', { status: 403 });
}
// Update role
await db.update(members)
.set({ role: newRole })
.where(and(
eq(members.userId, targetUserId),
eq(members.organizationId, userMembership.organizationId)
));
return redirect('/dashboard/team');
};

View File

@@ -0,0 +1,65 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users, members } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// Check if user is admin
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userMembership || (userMembership.role !== 'owner' && userMembership.role !== 'admin')) {
return new Response('Unauthorized', { status: 403 });
}
const formData = await request.formData();
const email = formData.get('email')?.toString();
const role = formData.get('role')?.toString() || 'member';
if (!email) {
return new Response('Email is required', { status: 400 });
}
if (!['member', 'admin'].includes(role)) {
return new Response('Invalid role', { status: 400 });
}
// Find user by email
const invitedUser = await db.select()
.from(users)
.where(eq(users.email, email))
.get();
if (!invitedUser) {
return new Response('User not found. They must create an account first.', { status: 404 });
}
// Check if already a member
const existingMember = await db.select()
.from(members)
.where(and(
eq(members.userId, invitedUser.id),
eq(members.organizationId, userMembership.organizationId)
))
.get();
if (existingMember) {
return new Response('User is already a member', { status: 400 });
}
// Add to organization
await db.insert(members).values({
userId: invitedUser.id,
organizationId: userMembership.organizationId,
role,
});
return redirect('/dashboard/team');
};

View File

@@ -0,0 +1,59 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { members } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// Check if user is admin
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userMembership || (userMembership.role !== 'owner' && userMembership.role !== 'admin')) {
return new Response('Unauthorized', { status: 403 });
}
const formData = await request.formData();
const targetUserId = formData.get('userId')?.toString();
if (!targetUserId) {
return new Response('Missing user ID', { status: 400 });
}
// Can't remove self
if (targetUserId === user.id) {
return new Response('Cannot remove yourself', { status: 403 });
}
// Can't remove owner
const targetMember = await db.select()
.from(members)
.where(and(
eq(members.userId, targetUserId),
eq(members.organizationId, userMembership.organizationId)
))
.get();
if (!targetMember) {
return new Response('Member not found', { status: 404 });
}
if (targetMember.role === 'owner') {
return new Response('Cannot remove owner', { status: 403 });
}
// Remove member
await db.delete(members)
.where(and(
eq(members.userId, targetUserId),
eq(members.organizationId, userMembership.organizationId)
));
return redirect('/dashboard/team');
};

View File

@@ -0,0 +1,36 @@
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 });
}
const entryId = params.id;
if (!entryId) {
return new Response(JSON.stringify({ error: 'Entry ID required' }), { status: 400 });
}
// Verify the entry belongs to the user
const entry = await db.select()
.from(timeEntries)
.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 });
}
// Delete the entry
await db.delete(timeEntries)
.where(eq(timeEntries.id, entryId))
.run();
return redirect('/dashboard/tracker');
};

View File

@@ -0,0 +1,78 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { timeEntries, members, timeEntryTags, categories } from '../../../db/schema';
import { eq, and, isNull } from 'drizzle-orm';
import { nanoid } from 'nanoid';
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const body = await request.json();
const description = body.description || '';
const clientId = body.clientId;
const categoryId = body.categoryId;
const tags = body.tags || [];
if (!clientId) {
return new Response('Client is required', { status: 400 });
}
if (!categoryId) {
return new Response('Category is required', { status: 400 });
}
// Check for running entry
const runningEntry = await db.select().from(timeEntries).where(
and(
eq(timeEntries.userId, locals.user.id),
isNull(timeEntries.endTime)
)
).get();
if (runningEntry) {
return new Response('Timer already running', { status: 400 });
}
// Get default org (first one)
const member = await db.select().from(members).where(eq(members.userId, locals.user.id)).limit(1).get();
if (!member) {
return new Response('No organization found', { status: 400 });
}
// Verify category belongs to user's organization
const category = await db.select().from(categories).where(
and(
eq(categories.id, categoryId),
eq(categories.organizationId, member.organizationId)
)
).get();
if (!category) {
return new Response('Invalid category', { status: 400 });
}
const startTime = new Date();
const id = nanoid();
await db.insert(timeEntries).values({
id,
userId: locals.user.id,
organizationId: member.organizationId,
clientId,
categoryId,
startTime,
description,
});
// Add tags if provided
if (tags.length > 0) {
await db.insert(timeEntryTags).values(
tags.map((tagId: string) => ({
timeEntryId: id,
tagId,
}))
);
}
return new Response(JSON.stringify({ id, startTime }), { status: 200 });
};

View File

@@ -0,0 +1,25 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { timeEntries } from '../../../db/schema';
import { eq, and, isNull } from 'drizzle-orm';
export const POST: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const runningEntry = await db.select().from(timeEntries).where(
and(
eq(timeEntries.userId, locals.user.id),
isNull(timeEntries.endTime)
)
).get();
if (!runningEntry) {
return new Response('No timer running', { status: 400 });
}
await db.update(timeEntries)
.set({ endTime: new Date() })
.where(eq(timeEntries.id, runningEntry.id));
return new Response(JSON.stringify({ success: true }), { status: 200 });
};