fix(vacay): enforce weekend blocking in toggleEntry service

With block_weekends on, toggling a weekend day via MCP or the plugin RPC
still added the entry and burned allowance — the guard lived only in the
web UI (mcp-smoke-test I-02). toggleEntry now rejects inserts and
in-place conversions on blocked days with { error: 'weekend_blocked' }
(the votePoll pattern); removing an existing entry stays allowed so
stray weekend entries remain cleanable. The controller maps the code to
a 400, the MCP tool to an isError result.
This commit is contained in:
jubnl
2026-08-12 00:38:41 +02:00
parent 705c3c08dd
commit 7e7277beb0
7 changed files with 149 additions and 15 deletions
+5 -1
View File
@@ -229,7 +229,11 @@ export class VacayController {
}
userId = tid;
}
return this.vacay.toggleEntry(userId, planId, body.date, body.fraction, body.kind, socketId);
const result = this.vacay.toggleEntry(userId, planId, body.date, body.fraction, body.kind, socketId);
if (result.error === 'weekend_blocked') {
throw new HttpException({ error: 'Weekend days are blocked on this plan' }, 400);
}
return result;
}
@Post('entries/company-holiday')
+1
View File
@@ -272,6 +272,7 @@ export class VacayMcp {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const result = this.vacay.toggleEntry(ctx.userId, planId, date, 1, 'vacation', undefined);
if (result.error) return errorResult(result.error);
return ok(result);
}
+19 -1
View File
@@ -16,6 +16,8 @@ export interface VacayPlan {
school_holidays_enabled: number;
company_holidays_enabled: number;
carry_over_enabled: number;
weekend_days: string | null;
week_start: number | null;
}
export interface VacayUserYear {
@@ -120,6 +122,16 @@ function normalizeKind(value: unknown): 'vacation' | 'comp' {
return value === 'comp' ? 'comp' : 'vacation';
}
/**
* Same semantics as the client's isWeekend guard (holidays.ts): UTC weekday,
* weekend_days falls back to Sat/Sun when the column is NULL or empty.
*/
function isBlockedWeekend(plan: Pick<VacayPlan, 'block_weekends' | 'weekend_days'>, date: string): boolean {
if (!plan.block_weekends) return false;
const days = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6];
return days.includes(new Date(date + 'T00:00:00Z').getUTCDay());
}
const pad2 = (n: number) => String(n).padStart(2, '0');
/** Days a month has in every year — February caps at 28 so no boundary lands on a date a common year lacks. */
@@ -1064,9 +1076,11 @@ export class VacayService {
return { entries, companyHolidays };
}
toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, kind?: unknown, socketId?: string): { action: string; fraction?: number; kind?: string } {
toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, kind?: unknown, socketId?: string): { action?: string; fraction?: number; kind?: string; error?: string } {
const frac = normalizeFraction(fraction);
const knd = normalizeKind(kind);
const plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId);
const weekendBlocked = plan ? isBlockedWeekend(plan, date) : false;
const existing = this.db.get<{ id: number; fraction: number; kind: string | null }>('SELECT id, fraction, kind FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?', userId, date, planId);
if (existing) {
// Clicking the exact same day again (same type AND same fraction) clears it;
@@ -1076,10 +1090,14 @@ export class VacayService {
this.notifyPlanUsers(planId, socketId);
return { action: 'removed' };
}
// Removing a stray entry on a blocked day stays possible; keeping one
// there (converted in place) does not.
if (weekendBlocked) return { error: 'weekend_blocked' };
this.db.run('UPDATE vacay_entries SET fraction = ?, kind = ? WHERE id = ?', frac, knd, existing.id);
this.notifyPlanUsers(planId, socketId);
return { action: 'updated', fraction: frac, kind: knd };
}
if (weekendBlocked) return { error: 'weekend_blocked' };
this.db.run('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)', planId, userId, date, '', frac, knd);
this.notifyPlanUsers(planId, socketId);
return { action: 'added', fraction: frac, kind: knd };