Files
accounted/lib/salary/absence.ts
T
c4adc8eb7d fix(salary): stop RLS from failing vab/parental absence registration (#1568)
Migration 20260517135000 rewrote the franvaro-specifikationsnummer trigger
functions to insert audit rows into salary_absence_franvaro_audit, a table
with RLS enabled and zero policies, while leaving the functions SECURITY
INVOKER (its comment claimed implicit SECURITY DEFINER, which is false in
Postgres). Every vab/parental insert from role authenticated (dashboard
absence POST, web /pending approval, in-app Assistenten chat) then failed
with 42501, surfaced as a generic 500, and left no diagnosable trace.

- New migration 20260813120000: ALTER both trigger functions to SECURITY
  DEFINER with search_path pinned to public, pg_temp. No RLS policy is added
  on the audit table: trigger/service-only writes stay the design intent.
- mapInsertError: 42501 now maps to the new bilingual DB_PERMISSION_DENIED
  code instead of INTERNAL_ERROR, and 23514 is split so only the 24h-cap
  trigger's 'Total tid' message becomes ABSENCE_HOURS_CONFLICT; other CHECK
  violations map to VALIDATION_ERROR.
- commitRegisterAbsence/commitDeleteAbsence: log the underlying PG details
  and persist the sanitized structured code in result_data.error_code so the
  next failure is traceable from the op row.
- Dashboard absence route: only ABSENCE_HOURS_CONFLICT passes details.message
  through to the client; every other code shows the registry Swedish message
  instead of raw Postgres text.
- New pg-real regression test locks the authenticated-role parental/vab
  insert path, the shared per-month specnummer sequence, the audit rows, and
  idempotent upsert retries.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:12:32 +02:00

314 lines
9.8 KiB
TypeScript

/**
* Shared absence (frånvaro) commands.
*
* Single source of truth for reading and writing salary_absence_days rows,
* consumed by the internal dashboard route
* (app/api/salary/employees/[id]/absence), the v1 REST routes, and the MCP
* staged-operation executor (register_absence).
*
* Storage is strictly PER-DAY rows: sjuklönelagen mechanics (karensavdrag
* boundary, återinsjuknande 5-day merge, högriskskydd 12-month cap, day 14/15
* FK transition) and AGI 2025+ per-event Frånvarouppgift are all derived from
* day rows by the calculation engine. The API accepts ranges for ergonomics
* and expands them server-side.
*
* Upserts use a native ON CONFLICT upsert on the natural key (employee,
* date, type): atomic and truly idempotent, so PUT retries are safe and a
* rejected write (e.g. the 24h cap) never drops existing rows.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
export type AbsenceResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface AbsenceDayRow {
id: string
absence_date: string
absence_type: string
hours: number
notes: string | null
salary_run_employee_id: string | null
created_at: string
updated_at: string
}
/** Hard cap on range size: one quarter + buffer. Keeps payloads bounded and
* makes the range the pagination (no cursor needed on the GET). */
export const ABSENCE_RANGE_MAX_DAYS = 92
const ABSENCE_COLUMNS =
'id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at'
async function assertEmployee(
supabase: SupabaseClient,
companyId: string,
employeeId: string,
): Promise<AbsenceResult<{ id: string }>> {
const { data, error } = await supabase
.from('employees')
.select('id')
.eq('id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
if (!data) {
return { ok: false, code: 'EMPLOYEE_NOT_FOUND' }
}
return { ok: true, data: data as { id: string } }
}
/** Expand [from, to] (inclusive, ISO dates) to per-day ISO strings.
* Returns null when the range is inverted or exceeds the cap. */
export function expandDateRange(
from: string,
to: string,
opts: { includeWeekends?: boolean } = {},
): string[] | null {
const start = Date.parse(`${from}T00:00:00Z`)
const end = Date.parse(`${to}T00:00:00Z`)
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) return null
const DAY_MS = 86_400_000
const spanDays = Math.round((end - start) / DAY_MS) + 1
if (spanDays > ABSENCE_RANGE_MAX_DAYS) return null
const dates: string[] = []
for (let t = start; t <= end; t += DAY_MS) {
const d = new Date(t)
const dow = d.getUTCDay() // 0 = Sunday, 6 = Saturday
if (!opts.includeWeekends && (dow === 0 || dow === 6)) continue
dates.push(d.toISOString().slice(0, 10))
}
return dates
}
function mapInsertError(error: { code?: string; message?: string }): {
code: string
details?: Record<string, unknown>
} {
// Privilege/RLS denial (42501). Seen when a DB trigger writes to a
// protected table as SECURITY INVOKER (the franvaro audit-table bug fixed
// in migration 20260813120000): a server-side configuration error, kept
// distinct from INTERNAL_ERROR so the failure mode is diagnosable.
if (error.code === '42501') {
return { code: 'DB_PERMISSION_DENIED', details: { message: error.message } }
}
// The 24h cap trigger raises check_violation with 'Total tid' text when
// worked + absence hours exceed 24h for the same date.
if (error.message?.includes('Total tid')) {
return { code: 'ABSENCE_HOURS_CONFLICT', details: { message: error.message } }
}
// Any other CHECK violation (hours range, absence_type enum) is invalid
// input, not an hours conflict.
if (error.code === '23514') {
return { code: 'VALIDATION_ERROR', details: { message: error.message } }
}
return { code: 'INTERNAL_ERROR', details: { message: error.message } }
}
export async function listAbsenceDays(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
from: string
to: string
absenceType?: string
},
): Promise<AbsenceResult<AbsenceDayRow[]>> {
const emp = await assertEmployee(supabase, args.companyId, args.employeeId)
if (!emp.ok) return emp
let query = supabase
.from('salary_absence_days')
.select(ABSENCE_COLUMNS)
.eq('company_id', args.companyId)
.eq('employee_id', args.employeeId)
.gte('absence_date', args.from)
.lte('absence_date', args.to)
.order('absence_date', { ascending: true })
if (args.absenceType) {
query = query.eq('absence_type', args.absenceType)
}
const { data, error } = await query
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: (data ?? []) as unknown as AbsenceDayRow[] }
}
export async function upsertAbsenceDay(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
day: {
absence_date: string
absence_type: string
hours: number
notes?: string | null
salary_run_employee_id?: string | null
}
},
): Promise<AbsenceResult<AbsenceDayRow>> {
const emp = await assertEmployee(supabase, args.companyId, args.employeeId)
if (!emp.ok) return emp
// Atomic upsert on the natural-key unique index: a rejected write (24h
// cap, constraint) leaves any existing row untouched.
const { data, error } = await supabase
.from('salary_absence_days')
.upsert(
{
company_id: args.companyId,
employee_id: args.employeeId,
absence_date: args.day.absence_date,
absence_type: args.day.absence_type,
hours: args.day.hours,
notes: args.day.notes ?? null,
salary_run_employee_id: args.day.salary_run_employee_id ?? null,
},
{ onConflict: 'employee_id,absence_date,absence_type' },
)
.select(ABSENCE_COLUMNS)
.single()
if (error) {
const mapped = mapInsertError(error)
return { ok: false, ...mapped }
}
return { ok: true, data: data as unknown as AbsenceDayRow }
}
export async function upsertAbsenceRange(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
from: string
to: string
absenceType: string
hoursPerDay?: number
notes?: string | null
includeWeekends?: boolean
/** Validate + expand only; return the would-be days without writing. */
dryRun?: boolean
},
): Promise<AbsenceResult<{ count: number; days: AbsenceDayRow[] | Array<{ absence_date: string; absence_type: string; hours: number }> }>> {
const emp = await assertEmployee(supabase, args.companyId, args.employeeId)
if (!emp.ok) return emp
const dates = expandDateRange(args.from, args.to, { includeWeekends: args.includeWeekends })
if (dates === null) {
return {
ok: false,
code: 'ABSENCE_RANGE_TOO_LARGE',
details: { from: args.from, to: args.to, max_days: ABSENCE_RANGE_MAX_DAYS },
}
}
const hours = args.hoursPerDay ?? 8
const rows = dates.map((absence_date) => ({
company_id: args.companyId,
employee_id: args.employeeId,
absence_date,
absence_type: args.absenceType,
hours,
notes: args.notes ?? null,
salary_run_employee_id: null,
}))
if (args.dryRun) {
return {
ok: true,
data: {
count: rows.length,
days: rows.map((r) => ({
absence_date: r.absence_date,
absence_type: r.absence_type,
hours: r.hours,
})),
},
}
}
if (rows.length === 0) {
return { ok: true, data: { count: 0, days: [] } }
}
// Bulk atomic upsert on the natural-key unique index (employee, date,
// type). One statement: a retry converges on the same end state, and a
// rejected write (e.g. the 24h cap on one day) rolls back the whole range
// without dropping the previously stored rows.
const { data, error } = await supabase
.from('salary_absence_days')
.upsert(rows, { onConflict: 'employee_id,absence_date,absence_type' })
.select(ABSENCE_COLUMNS)
if (error) {
const mapped = mapInsertError(error)
return { ok: false, ...mapped }
}
const inserted = (data ?? []) as unknown as AbsenceDayRow[]
return { ok: true, data: { count: inserted.length, days: inserted } }
}
export async function deleteAbsenceRange(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
from: string
to: string
absenceType?: string
/** Validate only; do not delete. */
dryRun?: boolean
},
): Promise<AbsenceResult<{ deleted_count: number }>> {
const emp = await assertEmployee(supabase, args.companyId, args.employeeId)
if (!emp.ok) return emp
if (args.dryRun) {
// Count what WOULD be deleted so the preview is informative.
let countQuery = supabase
.from('salary_absence_days')
.select('id', { count: 'exact', head: true })
.eq('company_id', args.companyId)
.eq('employee_id', args.employeeId)
.gte('absence_date', args.from)
.lte('absence_date', args.to)
if (args.absenceType) countQuery = countQuery.eq('absence_type', args.absenceType)
const { count, error } = await countQuery
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: { deleted_count: count ?? 0 } }
}
let query = supabase
.from('salary_absence_days')
.delete({ count: 'exact' })
.eq('company_id', args.companyId)
.eq('employee_id', args.employeeId)
.gte('absence_date', args.from)
.lte('absence_date', args.to)
if (args.absenceType) {
query = query.eq('absence_type', args.absenceType)
}
const { count, error } = await query
if (error) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
return { ok: true, data: { deleted_count: count ?? 0 } }
}