2.0.0
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m49s

This commit is contained in:
2026-01-18 14:27:47 -07:00
parent ce47de9e56
commit 5e70dd6bb8
8 changed files with 217 additions and 163 deletions

View File

@@ -2,9 +2,9 @@
A modern time tracking application built with Astro, Vue, and DaisyUI. A modern time tracking application built with Astro, Vue, and DaisyUI.
## Stack ## Stack
Frameword: Astro - Framework: Astro
Runtime: Node - Runtime: Node
UI Library: Vue 3 - UI Library: Vue 3
CSS and Styles: DaisyUI + Tailwind CSS - CSS and Styles: DaisyUI + Tailwind CSS
Database: libSQL - Database: libSQL
ORM: Drizzle ORM - ORM: Drizzle ORM

View File

@@ -1,7 +1,7 @@
{ {
"name": "chronus", "name": "chronus",
"type": "module", "type": "module",
"version": "1.3.0", "version": "2.0.0",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",

View File

@@ -5,6 +5,7 @@ import {
real, real,
primaryKey, primaryKey,
foreignKey, foreignKey,
index,
} from "drizzle-orm/sqlite-core"; } from "drizzle-orm/sqlite-core";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -57,6 +58,10 @@ export const members = sqliteTable(
columns: [table.organizationId], columns: [table.organizationId],
foreignColumns: [organizations.id], 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], columns: [table.organizationId],
foreignColumns: [organizations.id], foreignColumns: [organizations.id],
}), }),
organizationIdIdx: index("clients_organization_id_idx").on(
table.organizationId,
),
}), }),
); );
@@ -105,6 +113,9 @@ export const categories = sqliteTable(
columns: [table.organizationId], columns: [table.organizationId],
foreignColumns: [organizations.id], foreignColumns: [organizations.id],
}), }),
organizationIdIdx: index("categories_organization_id_idx").on(
table.organizationId,
),
}), }),
); );
@@ -143,6 +154,12 @@ export const timeEntries = sqliteTable(
columns: [table.categoryId], columns: [table.categoryId],
foreignColumns: [categories.id], 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], columns: [table.organizationId],
foreignColumns: [organizations.id], foreignColumns: [organizations.id],
}), }),
organizationIdIdx: index("tags_organization_id_idx").on(
table.organizationId,
),
}), }),
); );
@@ -183,6 +203,10 @@ export const timeEntryTags = sqliteTable(
columns: [table.tagId], columns: [table.tagId],
foreignColumns: [tags.id], 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], columns: [table.userId],
foreignColumns: [users.id], foreignColumns: [users.id],
}), }),
userIdIdx: index("sessions_user_id_idx").on(table.userId),
}), }),
); );
@@ -232,6 +257,7 @@ export const apiTokens = sqliteTable(
columns: [table.userId], columns: [table.userId],
foreignColumns: [users.id], foreignColumns: [users.id],
}), }),
userIdIdx: index("api_tokens_user_id_idx").on(table.userId),
}), }),
); );
@@ -267,6 +293,10 @@ export const invoices = sqliteTable(
columns: [table.clientId], columns: [table.clientId],
foreignColumns: [clients.id], 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], columns: [table.invoiceId],
foreignColumns: [invoices.id], foreignColumns: [invoices.id],
}), }),
invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId),
}), }),
); );

83
src/lib/validation.ts Normal file
View File

@@ -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 };
}

View File

@@ -37,6 +37,10 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
return redirect("/signup?error=missing_fields"); return redirect("/signup?error=missing_fields");
} }
if (password.length < 8) {
return redirect("/signup?error=password_too_short");
}
const existingUser = await db const existingUser = await db
.select() .select()
.from(users) .from(users)

View File

@@ -1,18 +1,19 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from "astro";
import { db } from '../../../db'; import { db } from "../../../db";
import { timeEntries, members, timeEntryTags, categories, clients } from '../../../db/schema'; import { timeEntries, members, timeEntryTags } from "../../../db/schema";
import { eq, and } from 'drizzle-orm'; import { eq } from "drizzle-orm";
import { nanoid } from 'nanoid'; import { nanoid } from "nanoid";
import {
validateTimeEntryResources,
validateTimeRange,
} from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) { if (!locals.user) {
return new Response( return new Response(JSON.stringify({ error: "Unauthorized" }), {
JSON.stringify({ error: 'Unauthorized' }), status: 401,
{ headers: { "Content-Type": "application/json" },
status: 401, });
headers: { 'Content-Type': 'application/json' }
}
);
} }
const body = await request.json(); const body = await request.json();
@@ -20,67 +21,47 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Validation // Validation
if (!clientId) { if (!clientId) {
return new Response( return new Response(JSON.stringify({ error: "Client is required" }), {
JSON.stringify({ error: 'Client is required' }), status: 400,
{ headers: { "Content-Type": "application/json" },
status: 400, });
headers: { 'Content-Type': 'application/json' }
}
);
} }
if (!categoryId) { if (!categoryId) {
return new Response( return new Response(JSON.stringify({ error: "Category is required" }), {
JSON.stringify({ error: 'Category is required' }), status: 400,
{ headers: { "Content-Type": "application/json" },
status: 400, });
headers: { 'Content-Type': 'application/json' }
}
);
} }
if (!startTime) { if (!startTime) {
return new Response( return new Response(JSON.stringify({ error: "Start time is required" }), {
JSON.stringify({ error: 'Start time is required' }), status: 400,
{ headers: { "Content-Type": "application/json" },
status: 400, });
headers: { 'Content-Type': 'application/json' }
}
);
} }
if (!endTime) { if (!endTime) {
return new Response( return new Response(JSON.stringify({ error: "End time is required" }), {
JSON.stringify({ error: 'End time is required' }), status: 400,
{ headers: { "Content-Type": "application/json" },
status: 400, });
headers: { 'Content-Type': 'application/json' }
}
);
} }
const startDate = new Date(startTime); const timeValidation = validateTimeRange(startTime, endTime);
const endDate = new Date(endTime);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { if (
return new Response( !timeValidation.valid ||
JSON.stringify({ error: 'Invalid date format' }), !timeValidation.startDate ||
{ !timeValidation.endDate
status: 400, ) {
headers: { 'Content-Type': 'application/json' } return new Response(JSON.stringify({ error: timeValidation.error }), {
} status: 400,
); headers: { "Content-Type": "application/json" },
});
} }
if (endDate <= startDate) { const { startDate, endDate } = timeValidation;
return new Response(
JSON.stringify({ error: 'End time must be after start time' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Get user's organization // Get user's organization
const member = await db const member = await db
@@ -91,57 +72,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
.get(); .get();
if (!member) { if (!member) {
return new Response( return new Response(JSON.stringify({ error: "No organization found" }), {
JSON.stringify({ error: 'No organization found' }), status: 400,
{ headers: { "Content-Type": "application/json" },
status: 400, });
headers: { 'Content-Type': 'application/json' }
}
);
} }
// Verify category belongs to organization const resourceValidation = await validateTimeEntryResources({
const category = await db organizationId: member.organizationId,
.select() clientId,
.from(categories) categoryId,
.where( tagIds: Array.isArray(tags) ? tags : undefined,
and( });
eq(categories.id, categoryId),
eq(categories.organizationId, member.organizationId)
)
)
.get();
if (!category) { if (!resourceValidation.valid) {
return new Response( return new Response(JSON.stringify({ error: resourceValidation.error }), {
JSON.stringify({ error: 'Invalid category' }), status: 400,
{ headers: { "Content-Type": "application/json" },
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' }
}
);
} }
const id = nanoid(); const id = nanoid();
@@ -166,7 +114,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
tags.map((tagId: string) => ({ tags.map((tagId: string) => ({
timeEntryId: id, timeEntryId: id,
tagId, tagId,
})) })),
); );
} }
@@ -179,17 +127,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
}), }),
{ {
status: 201, status: 201,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
} },
); );
} catch (error) { } catch (error) {
console.error('Error creating manual time entry:', error); console.error("Error creating manual time entry:", error);
return new Response( return new Response(
JSON.stringify({ error: 'Failed to create time entry' }), JSON.stringify({ error: "Failed to create time entry" }),
{ {
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' } headers: { "Content-Type": "application/json" },
} },
); );
} }
}; };

View File

@@ -1,13 +1,9 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { db } from "../../../db"; import { db } from "../../../db";
import { import { timeEntries, members, timeEntryTags } from "../../../db/schema";
timeEntries,
members,
timeEntryTags,
categories,
} from "../../../db/schema";
import { eq, and, isNull } from "drizzle-orm"; import { eq, and, isNull } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { validateTimeEntryResources } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response("Unauthorized", { status: 401 }); 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 }); return new Response("No organization found", { status: 400 });
} }
const category = await db const validation = await validateTimeEntryResources({
.select() organizationId: member.organizationId,
.from(categories) clientId,
.where( categoryId,
and( tagIds: tags,
eq(categories.id, categoryId), });
eq(categories.organizationId, member.organizationId),
),
)
.get();
if (!category) { if (!validation.valid) {
return new Response("Invalid category", { status: 400 }); return new Response(validation.error, { status: 400 });
} }
const startTime = new Date(); const startTime = new Date();

View File

@@ -41,52 +41,48 @@ if (currentOrg) {
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(now.getTime() - 30 * 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<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
})
.from(timeEntries) .from(timeEntries)
.where(and( .where(and(
eq(timeEntries.organizationId, currentOrg.organizationId), 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) => { stats.totalTimeThisWeek = weekStats?.totalDuration || 0;
if (e.endTime) {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}
return sum;
}, 0);
const monthEntries = await db.select() const monthStats = await db.select({
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
})
.from(timeEntries) .from(timeEntries)
.where(and( .where(and(
eq(timeEntries.organizationId, currentOrg.organizationId), 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) => { stats.totalTimeThisMonth = monthStats?.totalDuration || 0;
if (e.endTime) {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}
return sum;
}, 0);
const activeCount = await db.select() const activeCount = await db.select({ count: sql<number>`count(*)` })
.from(timeEntries) .from(timeEntries)
.where(and( .where(and(
eq(timeEntries.organizationId, currentOrg.organizationId), eq(timeEntries.organizationId, currentOrg.organizationId),
isNull(timeEntries.endTime) 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<number>`count(*)` })
.from(clients) .from(clients)
.where(eq(clients.organizationId, currentOrg.organizationId)) .where(eq(clients.organizationId, currentOrg.organizationId))
.all(); .get();
stats.totalClients = clientCount.length; stats.totalClients = clientCount?.count || 0;
stats.recentEntries = await db.select({ stats.recentEntries = await db.select({
entry: timeEntries, entry: timeEntries,