initial: pulsy from pulse-clock-main

This commit is contained in:
Hermes
2026-08-23 00:35:51 +00:00
commit 023f0394b4
53 changed files with 11171 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPulseSecret, verifyCookie, setAuthCookie, verifySecret } from '@/lib/auth-cookie';
export async function GET(request: NextRequest) {
if (verifyCookie(request)) {
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { secret } = body as { secret?: string };
if (!secret) {
return NextResponse.json({ error: 'Secret is required' }, { status: 400 });
}
if (!getPulseSecret()) {
return NextResponse.json(
{ error: 'Server not configured: PULSE_SECRET must be at least 32 characters' },
{ status: 500 },
);
}
if (!verifySecret(secret)) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}
const response = NextResponse.json({ ok: true });
setAuthCookie(response);
return response;
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, dbReady } from '@/db';
import { companies, employees, timeEntries, auditLog } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { CompanySchema, CompanyUpdateSchema } from '@/lib/schemas';
import { ok, err } from '@/lib/api-response';
export async function GET() {
await dbReady;
const results = await db.select().from(companies).all();
return NextResponse.json(ok(results));
}
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = CompanySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
await dbReady;
const company = await db.insert(companies).values(parsed.data).returning().get();
return NextResponse.json(ok({ company }));
}
export async function PATCH(request: NextRequest) {
const body = await request.json();
const parsed = CompanyUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
await dbReady;
const company = await db
.update(companies)
.set({ name: parsed.data.name })
.where(eq(companies.id, parsed.data.id))
.returning()
.get();
if (!company) {
return NextResponse.json(err('Company not found'), { status: 404 });
}
return NextResponse.json(ok({ company }));
}
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const parsed = id ? parseInt(id) : NaN;
if (isNaN(parsed)) {
return NextResponse.json(err('Invalid id'), { status: 400 });
}
await dbReady;
const deletedCompany = await db.transaction(async (tx) => {
await Promise.all([
tx.delete(auditLog).where(eq(auditLog.companyId, parsed)).run(),
tx.delete(timeEntries).where(eq(timeEntries.companyId, parsed)).run(),
]);
await tx.delete(employees).where(eq(employees.companyId, parsed)).run();
return tx.delete(companies).where(eq(companies.id, parsed)).returning().get();
});
if (!deletedCompany) {
return NextResponse.json(err('Company not found'), { status: 404 });
}
return NextResponse.json(ok({ company: deletedCompany }));
}
+83
View File
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, dbReady } from '@/db';
import { employees, companies, timeEntries } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { EmployeeSchema, EmployeeUpdateSchema } from '@/lib/schemas';
import { ok, err } from '@/lib/api-response';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const companyId = searchParams.get('companyId');
if (!companyId) {
return NextResponse.json(err('companyId is required'), { status: 400 });
}
await dbReady;
const result = await db
.select({
id: employees.id,
name: employees.name,
isActive: employees.isActive,
companyId: employees.companyId,
})
.from(employees)
.where(and(eq(employees.companyId, parseInt(companyId)), eq(employees.isActive, true)))
.all();
return NextResponse.json(ok(result));
}
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = EmployeeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
await dbReady;
const company = await db.select().from(companies).where(eq(companies.id, parsed.data.companyId)).get();
if (!company) {
return NextResponse.json(err('Company not found'), { status: 404 });
}
const employee = await db.insert(employees).values(parsed.data).returning().get();
return NextResponse.json(ok({ employee }));
}
export async function PATCH(request: NextRequest) {
const body = await request.json();
const parsed = EmployeeUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
const { id, ...rest } = parsed.data;
const updateData: Partial<typeof employees.$inferInsert> = {};
if (rest.name !== undefined) updateData.name = rest.name;
if (rest.isActive !== undefined) updateData.isActive = rest.isActive;
if (Object.keys(updateData).length === 0) {
return NextResponse.json(err('No fields to update'), { status: 400 });
}
await dbReady;
const employee = await db.update(employees).set(updateData).where(eq(employees.id, id)).returning().get();
if (!employee) {
return NextResponse.json(err('Employee not found'), { status: 404 });
}
return NextResponse.json(ok({ employee }));
}
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(err('id is required'), { status: 400 });
}
const parsedId = parseInt(id);
if (isNaN(parsedId)) {
return NextResponse.json(err('Invalid id'), { status: 400 });
}
await dbReady;
const deletedEmployee = await db.transaction(async (tx) => {
await tx.delete(timeEntries).where(eq(timeEntries.employeeId, parsedId)).run();
return tx.delete(employees).where(eq(employees.id, parsedId)).returning().get();
});
if (!deletedEmployee) {
return NextResponse.json(err('Employee not found'), { status: 404 });
}
return NextResponse.json(ok({ employee: deletedEmployee }));
}
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, dbReady } from '@/db';
import { employees, timeEntries } from '@/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { statusAfter } from '@/lib/validation';
import { ok, err } from '@/lib/api-response';
import { calculateLiveShiftElapsed } from '@/lib/calculations';
import { parseId } from '@/lib/params';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const companyId = parseId(searchParams.get('companyId'));
if (companyId == null) {
return NextResponse.json(err('valid companyId is required'), { status: 400 });
}
await dbReady;
const activeEmps = await db
.select({ id: employees.id, name: employees.name })
.from(employees)
.where(and(eq(employees.companyId, companyId), eq(employees.isActive, true)))
.all();
if (activeEmps.length === 0) {
return NextResponse.json(ok({ employees: [], fetchedAt: Math.floor(Date.now() / 1000) }));
}
const allEntries = await db
.select({
id: timeEntries.id,
employeeId: timeEntries.employeeId,
type: timeEntries.type,
timestamp: timeEntries.timestamp,
isDeleted: timeEntries.isDeleted,
})
.from(timeEntries)
.where(
and(
eq(timeEntries.companyId, companyId),
eq(timeEntries.isDeleted, false),
inArray(
timeEntries.employeeId,
activeEmps.map((emp) => emp.id),
),
),
)
.orderBy(timeEntries.employeeId, timeEntries.timestamp, timeEntries.id)
.all();
const employeeList = activeEmps.map((emp) => {
const entries = allEntries.filter((entry) => entry.employeeId === emp.id);
const latestEntry = entries.at(-1);
return {
id: emp.id,
name: emp.name,
status: latestEntry ? statusAfter(latestEntry.type) : 'OUT',
lastPunchTimestamp: latestEntry?.timestamp ?? null,
...calculateLiveShiftElapsed(entries),
};
});
return NextResponse.json(ok({ employees: employeeList, fetchedAt: Math.floor(Date.now() / 1000) }));
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export function GET() {
return NextResponse.json({ ok: true });
}
+131
View File
@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, dbReady } from '@/db';
import { employees, timeEntries, companies } from '@/db/schema';
import { eq, and, gte, lte } from 'drizzle-orm';
import { calculateDayMinutes, formatHours } from '@/lib/calculations';
import { format, addDays } from 'date-fns';
import { ok, err } from '@/lib/api-response';
import { parseId } from '@/lib/params';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const companyId = parseId(searchParams.get('companyId'));
const startDateStr = searchParams.get('startDate');
const endDateStr = searchParams.get('endDate');
if (companyId == null) {
return NextResponse.json(err('valid companyId is required'), { status: 400 });
}
if (!startDateStr || !endDateStr) {
return NextResponse.json(err('startDate and endDate are required'), { status: 400 });
}
const startDate = new Date(startDateStr + 'T00:00:00');
const endDate = new Date(endDateStr + 'T23:59:59');
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return NextResponse.json(err('Invalid date format'), { status: 400 });
}
const startUnix = Math.floor(startDate.getTime() / 1000);
const endUnix = Math.floor(endDate.getTime() / 1000);
const contextStartUnix = startUnix - 36 * 3600;
const contextEndUnix = endUnix + 36 * 3600;
await dbReady;
const [company, companyEmployees, allEntries] = await Promise.all([
db.select().from(companies).where(eq(companies.id, companyId)).get(),
db
.select()
.from(employees)
.where(and(eq(employees.companyId, companyId), eq(employees.isActive, true)))
.all(),
db
.select({
id: timeEntries.id,
employeeId: timeEntries.employeeId,
type: timeEntries.type,
timestamp: timeEntries.timestamp,
isDeleted: timeEntries.isDeleted,
})
.from(timeEntries)
.where(
and(
eq(timeEntries.companyId, companyId),
eq(timeEntries.isDeleted, false),
gte(timeEntries.timestamp, contextStartUnix),
lte(timeEntries.timestamp, contextEndUnix),
),
)
.orderBy(timeEntries.timestamp)
.all(),
]);
if (!company) {
return NextResponse.json(err('Company not found'), { status: 404 });
}
const days: string[] = [];
let dayDate = startDate;
while (dayDate <= endDate) {
days.push(format(dayDate, 'yyyy-MM-dd'));
dayDate = addDays(dayDate, 1);
}
const resultEmployees = companyEmployees.map((emp) => {
const empEntries = allEntries.filter((e) => e.employeeId === emp.id);
const empDays = days.map((dayStr) => {
const dayStart = new Date(dayStr + 'T00:00:00').getTime() / 1000;
const dayEnd = new Date(dayStr + 'T23:59:59').getTime() / 1000;
const dayEntries = empEntries.filter((e) => e.timestamp >= dayStart && e.timestamp <= dayEnd);
// Single call , errors are returned directly
const { clockMinutes, breakMinutes, workedMinutes, hasErrors, errors } = calculateDayMinutes(empEntries, dayStr);
const inEntry = dayEntries.find((e) => e.type === 'IN' && !e.isDeleted);
const outEntry = dayEntries.find((e) => e.type === 'OUT' && !e.isDeleted);
return {
date: dayStr,
inTime: inEntry ? format(new Date(inEntry.timestamp * 1000), 'h:mm a') : '',
outTime: outEntry ? format(new Date(outEntry.timestamp * 1000), 'h:mm a') : '',
breakMinutes: Math.round(breakMinutes),
clockMinutes: Math.round(clockMinutes),
workedMinutes: Math.round(workedMinutes),
hasErrors,
errors,
};
});
const totals = empDays.reduce(
(acc, day) => ({
clockMinutes: acc.clockMinutes + day.clockMinutes,
breakMinutes: acc.breakMinutes + day.breakMinutes,
workedMinutes: acc.workedMinutes + day.workedMinutes,
}),
{ clockMinutes: 0, breakMinutes: 0, workedMinutes: 0 },
);
return {
id: emp.id,
name: emp.name,
days: empDays,
totals,
};
});
let summary = 'Employee\tDate\tIn\tOut\tBreak\tNet\tNotes\n';
for (const emp of resultEmployees) {
for (const day of emp.days) {
const hasErrors = day.errors.length > 0;
summary += `${emp.name}\t${day.date}\t${day.inTime}\t${day.outTime}\t${formatHours(day.breakMinutes)}\t${formatHours(day.workedMinutes)}\t${hasErrors ? '[WARN] ' + day.errors.join('; ') : ''}\n`;
}
summary += `${emp.name}\tTOTAL\t\t\t${formatHours(emp.totals.breakMinutes)}\t${formatHours(emp.totals.workedMinutes)}\t\n`;
}
return NextResponse.json(
ok({
company: company.name,
period: { startDate: startDateStr, endDate: endDateStr },
employees: resultEmployees,
summary,
}),
);
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { PunchSchema } from '@/lib/schemas';
import { ok, err } from '@/lib/api-response';
import { createValidatedTimeEntry } from '@/lib/time-entry-service';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = PunchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
const { employeeId, companyId, type, photoBase64 } = parsed.data;
const result = await createValidatedTimeEntry({
employeeId,
companyId,
type,
photoBase64,
source: 'kiosk',
});
if (!result.ok) {
return NextResponse.json(err(result.error), { status: result.status });
}
return NextResponse.json(
ok({
entry: {
id: result.entry.id,
employeeId: result.entry.employeeId,
type: result.entry.type,
timestamp: result.entry.timestamp,
},
status: result.status,
}),
);
} catch (error) {
console.error('Punch error:', error);
return NextResponse.json(err('Internal server error'), { status: 500 });
}
}
+135
View File
@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, dbReady } from '@/db';
import { timeEntries, employees } from '@/db/schema';
import { eq, and, gte, lte } from 'drizzle-orm';
import { validateTimestampChronology } from '@/lib/validation';
import { ManualEntrySchema, TimeEntryUpdateSchema } from '@/lib/schemas';
import { ok, err } from '@/lib/api-response';
import { createValidatedTimeEntry } from '@/lib/time-entry-service';
function parseRangeParam(value: string, boundary: 'start' | 'end'): number {
if (/^\d+$/.test(value)) return Number(value);
const suffix = boundary === 'start' ? 'T00:00:00' : 'T23:59:59';
return Math.floor(new Date(`${value}${suffix}`).getTime() / 1000);
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const companyId = searchParams.get('companyId');
const employeeId = searchParams.get('employeeId');
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
if (!companyId) {
return NextResponse.json(err('companyId is required'), { status: 400 });
}
const whereConditions = [eq(timeEntries.companyId, parseInt(companyId))];
if (employeeId) whereConditions.push(eq(timeEntries.employeeId, parseInt(employeeId)));
if (startDate) whereConditions.push(gte(timeEntries.timestamp, parseRangeParam(startDate, 'start')));
if (endDate) whereConditions.push(lte(timeEntries.timestamp, parseRangeParam(endDate, 'end')));
await dbReady;
const results = await db
.select({
id: timeEntries.id,
employeeId: timeEntries.employeeId,
companyId: timeEntries.companyId,
type: timeEntries.type,
timestamp: timeEntries.timestamp,
photoBase64: timeEntries.photoBase64,
isDeleted: timeEntries.isDeleted,
employeeName: employees.name,
})
.from(timeEntries)
.innerJoin(employees, eq(timeEntries.employeeId, employees.id))
.where(and(...whereConditions))
.orderBy(timeEntries.timestamp)
.all();
return NextResponse.json(ok(results));
}
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const parsed = TimeEntryUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
const { id, isDeleted, timestamp, forceOverride } = parsed.data;
await dbReady;
const result = await db.transaction(async (tx) => {
const existing = await tx.select().from(timeEntries).where(eq(timeEntries.id, id)).get();
if (!existing) {
return { ok: false as const, status: 404, error: 'Entry not found' };
}
if (timestamp !== undefined && timestamp !== existing.timestamp) {
const entries = await tx
.select()
.from(timeEntries)
.where(
and(
eq(timeEntries.employeeId, existing.employeeId),
eq(timeEntries.companyId, existing.companyId),
eq(timeEntries.isDeleted, false),
),
)
.all();
const validation = validateTimestampChronology(entries, id, timestamp, !!forceOverride);
if (!validation.valid) {
return { ok: false as const, status: 400, error: validation.error ?? 'Validation failed' };
}
}
const updateData: Partial<typeof timeEntries.$inferInsert> = {};
if (isDeleted !== undefined) updateData.isDeleted = isDeleted;
if (timestamp !== undefined) updateData.timestamp = timestamp;
const updated = await tx.update(timeEntries).set(updateData).where(eq(timeEntries.id, id)).returning().get();
return { ok: true as const, entry: updated };
});
if (!result.ok) {
return NextResponse.json(err(result.error), { status: result.status });
}
return NextResponse.json(ok({ entry: result.entry }));
} catch (error) {
console.error('Update time entry error:', error);
return NextResponse.json(err('Internal server error'), { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = ManualEntrySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
const { employeeId, companyId, type, timestamp, photoBase64, forceOverride } = parsed.data;
const result = await createValidatedTimeEntry({
employeeId,
companyId,
type,
timestamp,
photoBase64: photoBase64 || null,
forceOverride: !!forceOverride,
source: 'manual',
});
if (!result.ok) {
return NextResponse.json(err(result.error), { status: result.status });
}
return NextResponse.json(ok({ entry: result.entry, overridden: result.overridden }));
} catch (error) {
console.error('Add manual entry error:', error);
return NextResponse.json(err('Internal server error'), { status: 500 });
}
}