diff --git a/README.md b/README.md index fff14a2..df14174 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ A modern time tracking application built with Astro, Vue, and DaisyUI. ## Stack -Frameword: Astro -Runtime: Node -UI Library: Vue 3 -CSS and Styles: DaisyUI + Tailwind CSS -Database: libSQL -ORM: Drizzle ORM +- Framework: Astro +- Runtime: Node +- UI Library: Vue 3 +- CSS and Styles: DaisyUI + Tailwind CSS +- Database: libSQL +- ORM: Drizzle ORM diff --git a/package.json b/package.json index 3e33c44..b67ea90 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronus", "type": "module", - "version": "1.3.0", + "version": "2.0.0", "scripts": { "dev": "astro dev", "build": "astro build", diff --git a/src/db/schema.ts b/src/db/schema.ts index 967133c..5db019c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -5,6 +5,7 @@ import { real, primaryKey, foreignKey, + index, } from "drizzle-orm/sqlite-core"; import { nanoid } from "nanoid"; @@ -57,6 +58,10 @@ export const members = sqliteTable( columns: [table.organizationId], foreignColumns: [organizations.id], }), + userIdIdx: index("members_user_id_idx").on(table.userId), + organizationIdIdx: index("members_organization_id_idx").on( + table.organizationId, + ), }), ); @@ -84,6 +89,9 @@ export const clients = sqliteTable( columns: [table.organizationId], foreignColumns: [organizations.id], }), + organizationIdIdx: index("clients_organization_id_idx").on( + table.organizationId, + ), }), ); @@ -105,6 +113,9 @@ export const categories = sqliteTable( columns: [table.organizationId], foreignColumns: [organizations.id], }), + organizationIdIdx: index("categories_organization_id_idx").on( + table.organizationId, + ), }), ); @@ -143,6 +154,12 @@ export const timeEntries = sqliteTable( columns: [table.categoryId], foreignColumns: [categories.id], }), + userIdIdx: index("time_entries_user_id_idx").on(table.userId), + organizationIdIdx: index("time_entries_organization_id_idx").on( + table.organizationId, + ), + clientIdIdx: index("time_entries_client_id_idx").on(table.clientId), + startTimeIdx: index("time_entries_start_time_idx").on(table.startTime), }), ); @@ -164,6 +181,9 @@ export const tags = sqliteTable( columns: [table.organizationId], foreignColumns: [organizations.id], }), + organizationIdIdx: index("tags_organization_id_idx").on( + table.organizationId, + ), }), ); @@ -183,6 +203,10 @@ export const timeEntryTags = sqliteTable( columns: [table.tagId], foreignColumns: [tags.id], }), + timeEntryIdIdx: index("time_entry_tags_time_entry_id_idx").on( + table.timeEntryId, + ), + tagIdIdx: index("time_entry_tags_tag_id_idx").on(table.tagId), }), ); @@ -198,6 +222,7 @@ export const sessions = sqliteTable( columns: [table.userId], foreignColumns: [users.id], }), + userIdIdx: index("sessions_user_id_idx").on(table.userId), }), ); @@ -232,6 +257,7 @@ export const apiTokens = sqliteTable( columns: [table.userId], foreignColumns: [users.id], }), + userIdIdx: index("api_tokens_user_id_idx").on(table.userId), }), ); @@ -267,6 +293,10 @@ export const invoices = sqliteTable( columns: [table.clientId], foreignColumns: [clients.id], }), + organizationIdIdx: index("invoices_organization_id_idx").on( + table.organizationId, + ), + clientIdIdx: index("invoices_client_id_idx").on(table.clientId), }), ); @@ -287,5 +317,6 @@ export const invoiceItems = sqliteTable( columns: [table.invoiceId], foreignColumns: [invoices.id], }), + invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId), }), ); diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..f19c8ab --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,83 @@ +import { db } from "../db"; +import { clients, categories, tags as tagsTable } from "../db/schema"; +import { eq, and, inArray } from "drizzle-orm"; + +export async function validateTimeEntryResources({ + organizationId, + clientId, + categoryId, + tagIds, +}: { + organizationId: string; + clientId: string; + categoryId: string; + tagIds?: string[]; +}) { + const [client, category] = await Promise.all([ + db + .select() + .from(clients) + .where( + and( + eq(clients.id, clientId), + eq(clients.organizationId, organizationId), + ), + ) + .get(), + db + .select() + .from(categories) + .where( + and( + eq(categories.id, categoryId), + eq(categories.organizationId, organizationId), + ), + ) + .get(), + ]); + + if (!client) { + return { valid: false, error: "Invalid client" }; + } + + if (!category) { + return { valid: false, error: "Invalid category" }; + } + + if (tagIds && tagIds.length > 0) { + const validTags = await db + .select() + .from(tagsTable) + .where( + and( + inArray(tagsTable.id, tagIds), + eq(tagsTable.organizationId, organizationId), + ), + ) + .all(); + + if (validTags.length !== tagIds.length) { + return { valid: false, error: "Invalid tags" }; + } + } + + return { valid: true }; +} + +export function validateTimeRange( + start: string | number | Date, + end: string | number | Date, +) { + const startDate = new Date(start); + const endDate = new Date(end); + + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return { valid: false, error: "Invalid date format" }; + } + + if (endDate <= startDate) { + return { valid: false, error: "End time must be after start time" }; + } + + return { valid: true, startDate, endDate }; +} diff --git a/src/pages/api/auth/signup.ts b/src/pages/api/auth/signup.ts index cde5cef..a03c708 100644 --- a/src/pages/api/auth/signup.ts +++ b/src/pages/api/auth/signup.ts @@ -37,6 +37,10 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => { return redirect("/signup?error=missing_fields"); } + if (password.length < 8) { + return redirect("/signup?error=password_too_short"); + } + const existingUser = await db .select() .from(users) diff --git a/src/pages/api/time-entries/manual.ts b/src/pages/api/time-entries/manual.ts index 0a54b72..6883b32 100644 --- a/src/pages/api/time-entries/manual.ts +++ b/src/pages/api/time-entries/manual.ts @@ -1,18 +1,19 @@ -import type { APIRoute } from 'astro'; -import { db } from '../../../db'; -import { timeEntries, members, timeEntryTags, categories, clients } from '../../../db/schema'; -import { eq, and } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; +import type { APIRoute } from "astro"; +import { db } from "../../../db"; +import { timeEntries, members, timeEntryTags } from "../../../db/schema"; +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { + validateTimeEntryResources, + validateTimeRange, +} from "../../../lib/validation"; export const POST: APIRoute = async ({ request, locals }) => { if (!locals.user) { - return new Response( - JSON.stringify({ error: 'Unauthorized' }), - { - status: 401, - headers: { 'Content-Type': 'application/json' } - } - ); + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); } const body = await request.json(); @@ -20,67 +21,47 @@ export const POST: APIRoute = async ({ request, locals }) => { // Validation if (!clientId) { - return new Response( - JSON.stringify({ error: 'Client is required' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } - ); + return new Response(JSON.stringify({ error: "Client is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } if (!categoryId) { - return new Response( - JSON.stringify({ error: 'Category is required' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } - ); + return new Response(JSON.stringify({ error: "Category is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } if (!startTime) { - return new Response( - JSON.stringify({ error: 'Start time is required' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } - ); + return new Response(JSON.stringify({ error: "Start time is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } if (!endTime) { - return new Response( - JSON.stringify({ error: 'End time is required' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } - ); + return new Response(JSON.stringify({ error: "End time is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } - const startDate = new Date(startTime); - const endDate = new Date(endTime); + const timeValidation = validateTimeRange(startTime, endTime); - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { - return new Response( - JSON.stringify({ error: 'Invalid date format' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } - ); + if ( + !timeValidation.valid || + !timeValidation.startDate || + !timeValidation.endDate + ) { + return new Response(JSON.stringify({ error: timeValidation.error }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } - if (endDate <= startDate) { - return new Response( - JSON.stringify({ error: 'End time must be after start time' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } - ); - } + const { startDate, endDate } = timeValidation; // Get user's organization const member = await db @@ -91,57 +72,24 @@ export const POST: APIRoute = async ({ request, locals }) => { .get(); if (!member) { - return new Response( - JSON.stringify({ error: 'No organization found' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } - ); + return new Response(JSON.stringify({ error: "No organization found" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } - // Verify category belongs to organization - const category = await db - .select() - .from(categories) - .where( - and( - eq(categories.id, categoryId), - eq(categories.organizationId, member.organizationId) - ) - ) - .get(); + const resourceValidation = await validateTimeEntryResources({ + organizationId: member.organizationId, + clientId, + categoryId, + tagIds: Array.isArray(tags) ? tags : undefined, + }); - if (!category) { - return new Response( - JSON.stringify({ error: 'Invalid category' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } - ); - } - - // Verify client belongs to organization - const client = await db - .select() - .from(clients) - .where( - and( - eq(clients.id, clientId), - eq(clients.organizationId, member.organizationId) - ) - ) - .get(); - - if (!client) { - return new Response( - JSON.stringify({ error: 'Invalid client' }), - { - status: 400, - headers: { 'Content-Type': 'application/json' } - } - ); + if (!resourceValidation.valid) { + return new Response(JSON.stringify({ error: resourceValidation.error }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } const id = nanoid(); @@ -166,7 +114,7 @@ export const POST: APIRoute = async ({ request, locals }) => { tags.map((tagId: string) => ({ timeEntryId: id, tagId, - })) + })), ); } @@ -179,17 +127,17 @@ export const POST: APIRoute = async ({ request, locals }) => { }), { status: 201, - headers: { 'Content-Type': 'application/json' } - } + headers: { "Content-Type": "application/json" }, + }, ); } catch (error) { - console.error('Error creating manual time entry:', error); + console.error("Error creating manual time entry:", error); return new Response( - JSON.stringify({ error: 'Failed to create time entry' }), + JSON.stringify({ error: "Failed to create time entry" }), { status: 500, - headers: { 'Content-Type': 'application/json' } - } + headers: { "Content-Type": "application/json" }, + }, ); } }; diff --git a/src/pages/api/time-entries/start.ts b/src/pages/api/time-entries/start.ts index 9bc0043..f59aa44 100644 --- a/src/pages/api/time-entries/start.ts +++ b/src/pages/api/time-entries/start.ts @@ -1,13 +1,9 @@ import type { APIRoute } from "astro"; import { db } from "../../../db"; -import { - timeEntries, - members, - timeEntryTags, - categories, -} from "../../../db/schema"; +import { timeEntries, members, timeEntryTags } from "../../../db/schema"; import { eq, and, isNull } from "drizzle-orm"; import { nanoid } from "nanoid"; +import { validateTimeEntryResources } from "../../../lib/validation"; export const POST: APIRoute = async ({ request, locals }) => { if (!locals.user) return new Response("Unauthorized", { status: 401 }); @@ -48,19 +44,15 @@ export const POST: APIRoute = async ({ request, locals }) => { return new Response("No organization found", { status: 400 }); } - const category = await db - .select() - .from(categories) - .where( - and( - eq(categories.id, categoryId), - eq(categories.organizationId, member.organizationId), - ), - ) - .get(); + const validation = await validateTimeEntryResources({ + organizationId: member.organizationId, + clientId, + categoryId, + tagIds: tags, + }); - if (!category) { - return new Response("Invalid category", { status: 400 }); + if (!validation.valid) { + return new Response(validation.error, { status: 400 }); } const startTime = new Date(); diff --git a/src/pages/dashboard/index.astro b/src/pages/dashboard/index.astro index ec636fd..17b1b4c 100644 --- a/src/pages/dashboard/index.astro +++ b/src/pages/dashboard/index.astro @@ -41,52 +41,48 @@ if (currentOrg) { const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - const weekEntries = await db.select() + const weekStats = await db.select({ + totalDuration: sql`sum(${timeEntries.endTime} - ${timeEntries.startTime})` + }) .from(timeEntries) .where(and( eq(timeEntries.organizationId, currentOrg.organizationId), - gte(timeEntries.startTime, weekAgo) + gte(timeEntries.startTime, weekAgo), + sql`${timeEntries.endTime} IS NOT NULL` )) - .all(); + .get(); - stats.totalTimeThisWeek = weekEntries.reduce((sum, e) => { - if (e.endTime) { - return sum + (e.endTime.getTime() - e.startTime.getTime()); - } - return sum; - }, 0); + stats.totalTimeThisWeek = weekStats?.totalDuration || 0; - const monthEntries = await db.select() + const monthStats = await db.select({ + totalDuration: sql`sum(${timeEntries.endTime} - ${timeEntries.startTime})` + }) .from(timeEntries) .where(and( eq(timeEntries.organizationId, currentOrg.organizationId), - gte(timeEntries.startTime, monthAgo) + gte(timeEntries.startTime, monthAgo), + sql`${timeEntries.endTime} IS NOT NULL` )) - .all(); + .get(); - stats.totalTimeThisMonth = monthEntries.reduce((sum, e) => { - if (e.endTime) { - return sum + (e.endTime.getTime() - e.startTime.getTime()); - } - return sum; - }, 0); + stats.totalTimeThisMonth = monthStats?.totalDuration || 0; - const activeCount = await db.select() + const activeCount = await db.select({ count: sql`count(*)` }) .from(timeEntries) .where(and( eq(timeEntries.organizationId, currentOrg.organizationId), isNull(timeEntries.endTime) )) - .all(); + .get(); - stats.activeTimers = activeCount.length; + stats.activeTimers = activeCount?.count || 0; - const clientCount = await db.select() + const clientCount = await db.select({ count: sql`count(*)` }) .from(clients) .where(eq(clients.organizationId, currentOrg.organizationId)) - .all(); + .get(); - stats.totalClients = clientCount.length; + stats.totalClients = clientCount?.count || 0; stats.recentEntries = await db.select({ entry: timeEntries,