Files
myeasycms-v2/packages/features/fischerei/src/server/api.ts
Zaid Marzguioui 5294cfab61
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m44s
Workflow / ⚫️ Test (push) Has been skipped
feat: enable Fischerei, Sitzungsprotokolle, Verbandsverwaltung modules
- Enable all 3 modules via NEXT_PUBLIC_ENABLE_* build args + runtime env
- Fix empty-string-to-null for date/optional columns in all module APIs:
  fischerei (24 fixes), verbandsverwaltung (15 fixes), sitzungsprotokolle (2 fixes)
- CACHE_BUST=12 for full rebuild with new feature flags
2026-04-01 13:23:57 +02:00

1459 lines
51 KiB
TypeScript

import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type {
CreateWaterInput,
UpdateWaterInput,
CreateFishSpeciesInput,
UpdateFishSpeciesInput,
CreateWaterSpeciesRuleInput,
CreateStockingInput,
UpdateStockingInput,
CreateLeaseInput,
UpdateLeaseInput,
CreateCatchBookInput,
UpdateCatchBookInput,
CreateCatchInput,
CreatePermitInput,
UpdatePermitInput,
CreateInspectorAssignmentInput,
CreateCompetitionInput,
UpdateCompetitionInput,
CreateCompetitionParticipantInput,
CreateSupplierInput,
UpdateSupplierInput,
} from '../schema/fischerei.schema';
/**
* Factory for the Fischerei (Fishing Association Management) API.
*/
export function createFischereiApi(client: SupabaseClient<Database>) {
return {
// =====================================================
// Waters
// =====================================================
async listWaters(
accountId: string,
opts?: {
search?: string;
waterType?: string;
archived?: boolean;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('waters')
.select(
'id, name, short_name, water_type, description, surface_area_ha, length_m, width_m, avg_depth_m, max_depth_m, outflow, location, county, geo_lat, geo_lng, lfv_number, lfv_name, cost_center_id, hejfish_id, is_archived, created_at, updated_at',
{ count: 'exact' },
)
.eq('account_id', accountId)
.order('name');
if (opts?.search) {
query = query.or(
`name.ilike.%${opts.search}%,short_name.ilike.%${opts.search}%,location.ilike.%${opts.search}%`,
);
}
if (opts?.waterType) {
query = query.eq(
'water_type',
opts.waterType as Database['public']['Enums']['water_type'],
);
}
if (opts?.archived !== undefined) {
query = query.eq('is_archived', opts.archived);
} else {
// By default, hide archived
query = query.eq('is_archived', false);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
async getWater(waterId: string) {
const { data, error } = await client
.from('waters')
.select('*')
.eq('id', waterId)
.single();
if (error) throw error;
return data;
},
async createWater(input: CreateWaterInput, userId: string) {
const { data, error } = await client
.from('waters')
.insert({
account_id: input.accountId,
name: input.name,
short_name: input.shortName || null,
water_type: input.waterType,
description: input.description || null,
surface_area_ha: input.surfaceAreaHa,
length_m: input.lengthM,
width_m: input.widthM,
avg_depth_m: input.avgDepthM,
max_depth_m: input.maxDepthM,
outflow: input.outflow || null,
location: input.location || null,
classification_order: input.classificationOrder,
county: input.county || null,
geo_lat: input.geoLat,
geo_lng: input.geoLng,
lfv_number: input.lfvNumber,
lfv_name: input.lfvName,
cost_share_ds: input.costShareDs,
cost_share_kalk: input.costShareKalk,
electrofishing_permit_requested: input.electrofishingPermitRequested,
hejfish_id: input.hejfishId,
cost_center_id: input.costCenterId,
is_archived: input.isArchived,
created_by: userId,
updated_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateWater(input: UpdateWaterInput, userId: string) {
const updateData: Record<string, unknown> = { updated_by: userId };
if (input.name !== undefined) updateData.name = input.name;
if (input.shortName !== undefined) updateData.short_name = input.shortName;
if (input.waterType !== undefined) updateData.water_type = input.waterType;
if (input.description !== undefined)
updateData.description = input.description;
if (input.surfaceAreaHa !== undefined)
updateData.surface_area_ha = input.surfaceAreaHa;
if (input.lengthM !== undefined) updateData.length_m = input.lengthM;
if (input.widthM !== undefined) updateData.width_m = input.widthM;
if (input.avgDepthM !== undefined)
updateData.avg_depth_m = input.avgDepthM;
if (input.maxDepthM !== undefined)
updateData.max_depth_m = input.maxDepthM;
if (input.outflow !== undefined) updateData.outflow = input.outflow;
if (input.location !== undefined) updateData.location = input.location;
if (input.classificationOrder !== undefined)
updateData.classification_order = input.classificationOrder;
if (input.county !== undefined) updateData.county = input.county;
if (input.geoLat !== undefined) updateData.geo_lat = input.geoLat;
if (input.geoLng !== undefined) updateData.geo_lng = input.geoLng;
if (input.lfvNumber !== undefined)
updateData.lfv_number = input.lfvNumber;
if (input.lfvName !== undefined) updateData.lfv_name = input.lfvName;
if (input.costShareDs !== undefined)
updateData.cost_share_ds = input.costShareDs;
if (input.costShareKalk !== undefined)
updateData.cost_share_kalk = input.costShareKalk;
if (input.electrofishingPermitRequested !== undefined)
updateData.electrofishing_permit_requested =
input.electrofishingPermitRequested;
if (input.hejfishId !== undefined)
updateData.hejfish_id = input.hejfishId;
if (input.costCenterId !== undefined)
updateData.cost_center_id = input.costCenterId;
if (input.isArchived !== undefined)
updateData.is_archived = input.isArchived;
const { data, error } = await client
.from('waters')
.update(updateData)
.eq('id', input.waterId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteWater(waterId: string) {
// Soft delete — set is_archived = true
const { error } = await client
.from('waters')
.update({ is_archived: true })
.eq('id', waterId);
if (error) throw error;
},
async getWaterDetail(waterId: string) {
// Fetch water + related data in parallel
const [waterResult, rulesResult, leasesResult, inspectorsResult, stockingResult] =
await Promise.all([
client.from('waters').select('*').eq('id', waterId).single(),
client
.from('water_species_rules')
.select(
'id, water_id, species_id, min_size_cm, protection_period_start, protection_period_end, max_catch_per_day, max_catch_per_year, created_at, fish_species ( id, name, name_latin )',
)
.eq('water_id', waterId),
client
.from('fishing_leases')
.select(
'id, lessor_name, start_date, end_date, initial_amount, fixed_annual_increase, percentage_annual_increase, payment_method, is_archived',
)
.eq('water_id', waterId)
.order('start_date', { ascending: false }),
client
.from('water_inspectors')
.select(
'id, water_id, member_id, assignment_start, assignment_end, created_at, members ( id, first_name, last_name )',
)
.eq('water_id', waterId),
client
.from('fish_stocking')
.select(
'id, stocking_date, quantity, weight_kg, age_class, cost_euros, remarks, fish_species ( id, name ), fish_suppliers ( id, name )',
)
.eq('water_id', waterId)
.order('stocking_date', { ascending: false })
.limit(20),
]);
if (waterResult.error) throw waterResult.error;
return {
water: waterResult.data,
speciesRules: rulesResult.data ?? [],
leases: leasesResult.data ?? [],
inspectors: inspectorsResult.data ?? [],
stockingHistory: stockingResult.data ?? [],
};
},
// =====================================================
// Fish Species
// =====================================================
async listSpecies(
accountId: string,
opts?: {
search?: string;
active?: boolean;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('fish_species')
.select(
'id, name, name_latin, name_local, is_active, max_age_years, max_weight_kg, max_length_cm, protected_min_size_cm, protection_period_start, protection_period_end, spawning_season_start, spawning_season_end, has_special_spawning_season, k_factor_avg, k_factor_min, k_factor_max, price_per_unit, max_catch_per_day, max_catch_per_year, individual_recording, sort_order, created_at, updated_at',
{ count: 'exact' },
)
.eq('account_id', accountId)
.order('sort_order')
.order('name');
if (opts?.search) {
query = query.or(
`name.ilike.%${opts.search}%,name_latin.ilike.%${opts.search}%,name_local.ilike.%${opts.search}%`,
);
}
if (opts?.active !== undefined) {
query = query.eq('is_active', opts.active);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 50;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
async getSpecies(speciesId: string) {
const { data, error } = await client
.from('fish_species')
.select('*')
.eq('id', speciesId)
.single();
if (error) throw error;
return data;
},
async createSpecies(input: CreateFishSpeciesInput) {
const { data, error } = await client
.from('fish_species')
.insert({
account_id: input.accountId,
name: input.name,
name_latin: input.nameLatin || null,
name_local: input.nameLocal || null,
is_active: input.isActive,
max_age_years: input.maxAgeYears,
max_weight_kg: input.maxWeightKg,
max_length_cm: input.maxLengthCm,
protected_min_size_cm: input.protectedMinSizeCm,
protection_period_start: input.protectionPeriodStart || null,
protection_period_end: input.protectionPeriodEnd || null,
spawning_season_start: input.spawningSeasonStart || null,
spawning_season_end: input.spawningSeasonEnd || null,
has_special_spawning_season: input.hasSpecialSpawningSeason,
k_factor_avg: input.kFactorAvg,
k_factor_min: input.kFactorMin,
k_factor_max: input.kFactorMax,
price_per_unit: input.pricePerUnit,
max_catch_per_day: input.maxCatchPerDay,
max_catch_per_year: input.maxCatchPerYear,
individual_recording: input.individualRecording,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateSpecies(input: UpdateFishSpeciesInput) {
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.nameLatin !== undefined)
updateData.name_latin = input.nameLatin;
if (input.nameLocal !== undefined)
updateData.name_local = input.nameLocal;
if (input.isActive !== undefined) updateData.is_active = input.isActive;
if (input.maxAgeYears !== undefined)
updateData.max_age_years = input.maxAgeYears;
if (input.maxWeightKg !== undefined)
updateData.max_weight_kg = input.maxWeightKg;
if (input.maxLengthCm !== undefined)
updateData.max_length_cm = input.maxLengthCm;
if (input.protectedMinSizeCm !== undefined)
updateData.protected_min_size_cm = input.protectedMinSizeCm;
if (input.protectionPeriodStart !== undefined)
updateData.protection_period_start = input.protectionPeriodStart;
if (input.protectionPeriodEnd !== undefined)
updateData.protection_period_end = input.protectionPeriodEnd;
if (input.spawningSeasonStart !== undefined)
updateData.spawning_season_start = input.spawningSeasonStart;
if (input.spawningSeasonEnd !== undefined)
updateData.spawning_season_end = input.spawningSeasonEnd;
if (input.hasSpecialSpawningSeason !== undefined)
updateData.has_special_spawning_season =
input.hasSpecialSpawningSeason;
if (input.kFactorAvg !== undefined)
updateData.k_factor_avg = input.kFactorAvg;
if (input.kFactorMin !== undefined)
updateData.k_factor_min = input.kFactorMin;
if (input.kFactorMax !== undefined)
updateData.k_factor_max = input.kFactorMax;
if (input.pricePerUnit !== undefined)
updateData.price_per_unit = input.pricePerUnit;
if (input.maxCatchPerDay !== undefined)
updateData.max_catch_per_day = input.maxCatchPerDay;
if (input.maxCatchPerYear !== undefined)
updateData.max_catch_per_year = input.maxCatchPerYear;
if (input.individualRecording !== undefined)
updateData.individual_recording = input.individualRecording;
const { data, error } = await client
.from('fish_species')
.update(updateData)
.eq('id', input.speciesId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteSpecies(speciesId: string) {
// Hard delete — will fail if catches reference it (RESTRICT FK)
const { error } = await client
.from('fish_species')
.delete()
.eq('id', speciesId);
if (error) throw error;
},
// =====================================================
// Water-Species Rules
// =====================================================
async listWaterSpeciesRules(waterId: string) {
const { data, error } = await client
.from('water_species_rules')
.select(
'id, water_id, species_id, min_size_cm, protection_period_start, protection_period_end, max_catch_per_day, max_catch_per_year, created_at, fish_species ( id, name, name_latin )',
)
.eq('water_id', waterId);
if (error) throw error;
return data ?? [];
},
async upsertWaterSpeciesRule(input: CreateWaterSpeciesRuleInput) {
const { data, error } = await client
.from('water_species_rules')
.upsert(
{
water_id: input.waterId,
species_id: input.speciesId,
min_size_cm: input.minSizeCm,
protection_period_start: input.protectionPeriodStart || null,
protection_period_end: input.protectionPeriodEnd || null,
max_catch_per_day: input.maxCatchPerDay,
max_catch_per_year: input.maxCatchPerYear,
},
{ onConflict: 'water_id,species_id' },
)
.select()
.single();
if (error) throw error;
return data;
},
async deleteWaterSpeciesRule(ruleId: string) {
const { error } = await client
.from('water_species_rules')
.delete()
.eq('id', ruleId);
if (error) throw error;
},
// =====================================================
// Stocking
// =====================================================
async listStocking(
accountId: string,
opts?: {
waterId?: string;
speciesId?: string;
year?: number;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('fish_stocking')
.select(
'id, water_id, species_id, stocking_date, quantity, weight_kg, age_class, cost_euros, supplier_id, remarks, created_at, waters ( id, name ), fish_species ( id, name ), fish_suppliers ( id, name )',
{ count: 'exact' },
)
.eq('account_id', accountId)
.order('stocking_date', { ascending: false });
if (opts?.waterId) {
query = query.eq('water_id', opts.waterId);
}
if (opts?.speciesId) {
query = query.eq('species_id', opts.speciesId);
}
if (opts?.year) {
query = query
.gte('stocking_date', `${opts.year}-01-01`)
.lte('stocking_date', `${opts.year}-12-31`);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
async createStocking(input: CreateStockingInput, userId: string) {
const { data, error } = await client
.from('fish_stocking')
.insert({
account_id: input.accountId,
water_id: input.waterId,
species_id: input.speciesId,
stocking_date: input.stockingDate || null,
quantity: input.quantity,
weight_kg: input.weightKg,
age_class: input.ageClass,
cost_euros: input.costEuros,
supplier_id: input.supplierId,
remarks: input.remarks || null,
created_by: userId,
updated_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateStocking(input: UpdateStockingInput, userId: string) {
const updateData: Record<string, unknown> = { updated_by: userId };
if (input.waterId !== undefined) updateData.water_id = input.waterId;
if (input.speciesId !== undefined)
updateData.species_id = input.speciesId;
if (input.stockingDate !== undefined)
updateData.stocking_date = input.stockingDate;
if (input.quantity !== undefined) updateData.quantity = input.quantity;
if (input.weightKg !== undefined) updateData.weight_kg = input.weightKg;
if (input.ageClass !== undefined) updateData.age_class = input.ageClass;
if (input.costEuros !== undefined)
updateData.cost_euros = input.costEuros;
if (input.supplierId !== undefined)
updateData.supplier_id = input.supplierId;
if (input.remarks !== undefined) updateData.remarks = input.remarks;
const { data, error } = await client
.from('fish_stocking')
.update(updateData)
.eq('id', input.stockingId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteStocking(stockingId: string) {
const { error } = await client
.from('fish_stocking')
.delete()
.eq('id', stockingId);
if (error) throw error;
},
// =====================================================
// Leases
// =====================================================
async listLeases(
accountId: string,
opts?: {
waterId?: string;
active?: boolean;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('fishing_leases')
.select(
'id, water_id, lessor_name, lessor_address, lessor_phone, lessor_email, start_date, end_date, duration_years, initial_amount, fixed_annual_increase, percentage_annual_increase, payment_method, account_holder, iban, bic, location_details, special_agreements, is_archived, created_at, updated_at, waters ( id, name )',
{ count: 'exact' },
)
.eq('account_id', accountId)
.order('start_date', { ascending: false });
if (opts?.waterId) {
query = query.eq('water_id', opts.waterId);
}
if (opts?.active) {
const today = new Date().toISOString().split('T')[0]!;
query = query
.eq('is_archived', false)
.or(`end_date.is.null,end_date.gte.${today}`);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
async getLease(leaseId: string) {
const { data, error } = await client
.from('fishing_leases')
.select('*, waters ( id, name )')
.eq('id', leaseId)
.single();
if (error) throw error;
return data;
},
async createLease(input: CreateLeaseInput, userId: string) {
const { data, error } = await client
.from('fishing_leases')
.insert({
account_id: input.accountId,
water_id: input.waterId,
lessor_name: input.lessorName,
lessor_address: input.lessorAddress || null,
lessor_phone: input.lessorPhone || null,
lessor_email: input.lessorEmail || null,
start_date: input.startDate || null,
end_date: input.endDate || null,
duration_years: input.durationYears,
initial_amount: input.initialAmount,
fixed_annual_increase: input.fixedAnnualIncrease,
percentage_annual_increase: input.percentageAnnualIncrease,
payment_method: input.paymentMethod,
account_holder: input.accountHolder,
iban: input.iban,
bic: input.bic,
location_details: input.locationDetails,
special_agreements: input.specialAgreements,
is_archived: input.isArchived,
created_by: userId,
updated_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateLease(input: UpdateLeaseInput, userId: string) {
const updateData: Record<string, unknown> = { updated_by: userId };
if (input.waterId !== undefined) updateData.water_id = input.waterId;
if (input.lessorName !== undefined)
updateData.lessor_name = input.lessorName;
if (input.lessorAddress !== undefined)
updateData.lessor_address = input.lessorAddress;
if (input.lessorPhone !== undefined)
updateData.lessor_phone = input.lessorPhone;
if (input.lessorEmail !== undefined)
updateData.lessor_email = input.lessorEmail;
if (input.startDate !== undefined)
updateData.start_date = input.startDate;
if (input.endDate !== undefined) updateData.end_date = input.endDate;
if (input.durationYears !== undefined)
updateData.duration_years = input.durationYears;
if (input.initialAmount !== undefined)
updateData.initial_amount = input.initialAmount;
if (input.fixedAnnualIncrease !== undefined)
updateData.fixed_annual_increase = input.fixedAnnualIncrease;
if (input.percentageAnnualIncrease !== undefined)
updateData.percentage_annual_increase = input.percentageAnnualIncrease;
if (input.paymentMethod !== undefined)
updateData.payment_method = input.paymentMethod;
if (input.accountHolder !== undefined)
updateData.account_holder = input.accountHolder;
if (input.iban !== undefined) updateData.iban = input.iban;
if (input.bic !== undefined) updateData.bic = input.bic;
if (input.locationDetails !== undefined)
updateData.location_details = input.locationDetails;
if (input.specialAgreements !== undefined)
updateData.special_agreements = input.specialAgreements;
if (input.isArchived !== undefined)
updateData.is_archived = input.isArchived;
const { data, error } = await client
.from('fishing_leases')
.update(updateData)
.eq('id', input.leaseId)
.select()
.single();
if (error) throw error;
return data;
},
// =====================================================
// Catch Books
// =====================================================
async listCatchBooks(
accountId: string,
opts?: {
year?: number;
status?: string;
memberId?: string;
search?: string;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('catch_books')
.select(
'id, member_id, year, member_name, member_birth_date, fishing_days_count, total_fish_caught, card_numbers, is_fly_fisher, status, verification, is_checked, is_submitted, submitted_at, is_hejfish, is_empty, not_fished, remarks, created_at, updated_at, members ( id, first_name, last_name )',
{ count: 'exact' },
)
.eq('account_id', accountId)
.order('year', { ascending: false })
.order('member_name');
if (opts?.year) {
query = query.eq('year', opts.year);
}
if (opts?.status) {
query = query.eq(
'status',
opts.status as Database['public']['Enums']['catch_book_status'],
);
}
if (opts?.memberId) {
query = query.eq('member_id', opts.memberId);
}
if (opts?.search) {
query = query.ilike('member_name', `%${opts.search}%`);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
async getCatchBook(catchBookId: string) {
const { data, error } = await client
.from('catch_books')
.select('*, members ( id, first_name, last_name )')
.eq('id', catchBookId)
.single();
if (error) throw error;
return data;
},
async createCatchBook(input: CreateCatchBookInput, userId: string) {
const { data, error } = await client
.from('catch_books')
.insert({
account_id: input.accountId,
member_id: input.memberId,
year: input.year,
member_name: input.memberName,
member_birth_date: input.memberBirthDate || null,
fishing_days_count: input.fishingDaysCount,
card_numbers: input.cardNumbers,
is_fly_fisher: input.isFlyFisher,
is_hejfish: input.isHejfish,
is_empty: input.isEmpty,
not_fished: input.notFished,
remarks: input.remarks || null,
created_by: userId,
updated_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateCatchBook(input: UpdateCatchBookInput, userId: string) {
const updateData: Record<string, unknown> = { updated_by: userId };
if (input.memberId !== undefined)
updateData.member_id = input.memberId;
if (input.year !== undefined) updateData.year = input.year;
if (input.memberName !== undefined)
updateData.member_name = input.memberName;
if (input.memberBirthDate !== undefined)
updateData.member_birth_date = input.memberBirthDate;
if (input.fishingDaysCount !== undefined)
updateData.fishing_days_count = input.fishingDaysCount;
if (input.cardNumbers !== undefined)
updateData.card_numbers = input.cardNumbers;
if (input.isFlyFisher !== undefined)
updateData.is_fly_fisher = input.isFlyFisher;
if (input.status !== undefined) updateData.status = input.status;
if (input.verification !== undefined)
updateData.verification = input.verification;
if (input.isChecked !== undefined)
updateData.is_checked = input.isChecked;
if (input.isSubmitted !== undefined)
updateData.is_submitted = input.isSubmitted;
if (input.isHejfish !== undefined)
updateData.is_hejfish = input.isHejfish;
if (input.isEmpty !== undefined) updateData.is_empty = input.isEmpty;
if (input.notFished !== undefined)
updateData.not_fished = input.notFished;
if (input.remarks !== undefined) updateData.remarks = input.remarks;
const { data, error } = await client
.from('catch_books')
.update(updateData)
.eq('id', input.catchBookId)
.select()
.single();
if (error) throw error;
return data;
},
async submitCatchBook(catchBookId: string, userId: string) {
const { data, error } = await client
.from('catch_books')
.update({
status: 'eingereicht' as Database['public']['Enums']['catch_book_status'],
is_submitted: true,
submitted_at: new Date().toISOString(),
updated_by: userId,
})
.eq('id', catchBookId)
.select()
.single();
if (error) throw error;
return data;
},
async reviewCatchBook(
catchBookId: string,
userId: string,
status: 'akzeptiert' | 'abgelehnt',
verification?: string,
remarks?: string,
) {
const updateData: Record<string, unknown> = {
status: status as Database['public']['Enums']['catch_book_status'],
is_checked: true,
updated_by: userId,
};
if (verification !== undefined) updateData.verification = verification;
if (remarks !== undefined) updateData.remarks = remarks;
const { data, error } = await client
.from('catch_books')
.update(updateData)
.eq('id', catchBookId)
.select()
.single();
if (error) throw error;
return data;
},
// =====================================================
// Catches
// =====================================================
async listCatches(
catchBookId: string,
opts?: { page?: number; pageSize?: number },
) {
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 50;
const { data, error, count } = await client
.from('catches')
.select(
'id, catch_book_id, species_id, water_id, member_id, catch_date, quantity, length_cm, weight_g, size_category, gender, is_empty_entry, has_error, hejfish_id, competition_id, competition_participant_id, permit_id, remarks, created_at, fish_species ( id, name ), waters ( id, name )',
{ count: 'exact' },
)
.eq('catch_book_id', catchBookId)
.order('catch_date', { ascending: false })
.range((page - 1) * pageSize, page * pageSize - 1);
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
async createCatch(input: CreateCatchInput) {
const { data, error } = await client
.from('catches')
.insert({
catch_book_id: input.catchBookId,
species_id: input.speciesId,
water_id: input.waterId,
member_id: input.memberId,
catch_date: input.catchDate || null,
quantity: input.quantity,
length_cm: input.lengthCm,
weight_g: input.weightG,
size_category: input.sizeCategory,
gender: input.gender,
is_empty_entry: input.isEmptyEntry,
has_error: input.hasError,
hejfish_id: input.hejfishId,
competition_id: input.competitionId,
competition_participant_id: input.competitionParticipantId,
permit_id: input.permitId,
remarks: input.remarks || null,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateCatch(
catchId: string,
input: Partial<CreateCatchInput>,
) {
const updateData: Record<string, unknown> = {};
if (input.speciesId !== undefined)
updateData.species_id = input.speciesId;
if (input.waterId !== undefined) updateData.water_id = input.waterId;
if (input.catchDate !== undefined)
updateData.catch_date = input.catchDate;
if (input.quantity !== undefined) updateData.quantity = input.quantity;
if (input.lengthCm !== undefined)
updateData.length_cm = input.lengthCm;
if (input.weightG !== undefined) updateData.weight_g = input.weightG;
if (input.sizeCategory !== undefined)
updateData.size_category = input.sizeCategory;
if (input.gender !== undefined) updateData.gender = input.gender;
if (input.isEmptyEntry !== undefined)
updateData.is_empty_entry = input.isEmptyEntry;
if (input.hasError !== undefined)
updateData.has_error = input.hasError;
if (input.remarks !== undefined) updateData.remarks = input.remarks;
const { data, error } = await client
.from('catches')
.update(updateData)
.eq('id', catchId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteCatch(catchId: string) {
const { error } = await client
.from('catches')
.delete()
.eq('id', catchId);
if (error) throw error;
},
async getCatchStatistics(
accountId: string,
year?: number,
waterId?: string,
) {
const { data, error } = await client.rpc('get_catch_statistics', {
p_account_id: accountId,
p_year: year ?? null,
p_water_id: waterId ?? null,
});
if (error) throw error;
return data ?? [];
},
// =====================================================
// Permits
// =====================================================
async listPermits(
accountId: string,
opts?: { archived?: boolean },
) {
let query = client
.from('fishing_permits')
.select(
'id, name, short_code, primary_water_id, total_quantity, cost_center_id, hejfish_id, is_for_sale, is_archived, created_at, updated_at, waters ( id, name )',
)
.eq('account_id', accountId)
.order('name');
if (opts?.archived !== undefined) {
query = query.eq('is_archived', opts.archived);
} else {
query = query.eq('is_archived', false);
}
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
async createPermit(input: CreatePermitInput) {
const { data, error } = await client
.from('fishing_permits')
.insert({
account_id: input.accountId,
name: input.name,
short_code: input.shortCode,
primary_water_id: input.primaryWaterId,
total_quantity: input.totalQuantity,
cost_center_id: input.costCenterId,
hejfish_id: input.hejfishId,
is_for_sale: input.isForSale,
is_archived: input.isArchived,
})
.select()
.single();
if (error) throw error;
return data;
},
async updatePermit(input: UpdatePermitInput) {
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.shortCode !== undefined)
updateData.short_code = input.shortCode;
if (input.primaryWaterId !== undefined)
updateData.primary_water_id = input.primaryWaterId;
if (input.totalQuantity !== undefined)
updateData.total_quantity = input.totalQuantity;
if (input.costCenterId !== undefined)
updateData.cost_center_id = input.costCenterId;
if (input.hejfishId !== undefined)
updateData.hejfish_id = input.hejfishId;
if (input.isForSale !== undefined)
updateData.is_for_sale = input.isForSale;
if (input.isArchived !== undefined)
updateData.is_archived = input.isArchived;
const { data, error } = await client
.from('fishing_permits')
.update(updateData)
.eq('id', input.permitId)
.select()
.single();
if (error) throw error;
return data;
},
// =====================================================
// Inspectors
// =====================================================
async listInspectors(waterId: string) {
const { data, error } = await client
.from('water_inspectors')
.select(
'id, water_id, member_id, assignment_start, assignment_end, created_at, members ( id, first_name, last_name )',
)
.eq('water_id', waterId)
.order('assignment_start', { ascending: false });
if (error) throw error;
return data ?? [];
},
async assignInspector(input: CreateInspectorAssignmentInput) {
const { data, error } = await client
.from('water_inspectors')
.insert({
account_id: input.accountId,
water_id: input.waterId,
member_id: input.memberId,
assignment_start: input.assignmentStart,
assignment_end: input.assignmentEnd,
})
.select()
.single();
if (error) throw error;
return data;
},
async removeInspector(inspectorId: string) {
const { error } = await client
.from('water_inspectors')
.delete()
.eq('id', inspectorId);
if (error) throw error;
},
// =====================================================
// Competitions
// =====================================================
async listCompetitions(
accountId: string,
opts?: { year?: number; page?: number; pageSize?: number },
) {
let query = client
.from('competitions')
.select(
'id, name, competition_date, event_id, permit_id, water_id, max_participants, score_by_count, score_by_heaviest, score_by_total_weight, score_by_longest, score_by_total_length, separate_member_guest_scoring, result_count_weight, result_count_length, result_count_count, created_at, updated_at, waters ( id, name )',
{ count: 'exact' },
)
.eq('account_id', accountId)
.order('competition_date', { ascending: false });
if (opts?.year) {
query = query
.gte('competition_date', `${opts.year}-01-01`)
.lte('competition_date', `${opts.year}-12-31`);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
async getCompetition(competitionId: string) {
const { data, error } = await client
.from('competitions')
.select('*, waters ( id, name )')
.eq('id', competitionId)
.single();
if (error) throw error;
return data;
},
async createCompetition(input: CreateCompetitionInput, userId: string) {
const { data, error } = await client
.from('competitions')
.insert({
account_id: input.accountId,
name: input.name,
competition_date: input.competitionDate,
event_id: input.eventId,
permit_id: input.permitId,
water_id: input.waterId,
max_participants: input.maxParticipants,
score_by_count: input.scoreByCount,
score_by_heaviest: input.scoreByHeaviest,
score_by_total_weight: input.scoreByTotalWeight,
score_by_longest: input.scoreByLongest,
score_by_total_length: input.scoreByTotalLength,
separate_member_guest_scoring: input.separateMemberGuestScoring,
result_count_weight: input.resultCountWeight,
result_count_length: input.resultCountLength,
result_count_count: input.resultCountCount,
created_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateCompetition(input: UpdateCompetitionInput, userId: string) {
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.competitionDate !== undefined)
updateData.competition_date = input.competitionDate;
if (input.eventId !== undefined) updateData.event_id = input.eventId;
if (input.permitId !== undefined)
updateData.permit_id = input.permitId;
if (input.waterId !== undefined) updateData.water_id = input.waterId;
if (input.maxParticipants !== undefined)
updateData.max_participants = input.maxParticipants;
if (input.scoreByCount !== undefined)
updateData.score_by_count = input.scoreByCount;
if (input.scoreByHeaviest !== undefined)
updateData.score_by_heaviest = input.scoreByHeaviest;
if (input.scoreByTotalWeight !== undefined)
updateData.score_by_total_weight = input.scoreByTotalWeight;
if (input.scoreByLongest !== undefined)
updateData.score_by_longest = input.scoreByLongest;
if (input.scoreByTotalLength !== undefined)
updateData.score_by_total_length = input.scoreByTotalLength;
if (input.separateMemberGuestScoring !== undefined)
updateData.separate_member_guest_scoring =
input.separateMemberGuestScoring;
if (input.resultCountWeight !== undefined)
updateData.result_count_weight = input.resultCountWeight;
if (input.resultCountLength !== undefined)
updateData.result_count_length = input.resultCountLength;
if (input.resultCountCount !== undefined)
updateData.result_count_count = input.resultCountCount;
const { data, error } = await client
.from('competitions')
.update(updateData)
.eq('id', input.competitionId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteCompetition(competitionId: string) {
const { error } = await client
.from('competitions')
.delete()
.eq('id', competitionId);
if (error) throw error;
},
async listParticipants(competitionId: string) {
const { data, error } = await client
.from('competition_participants')
.select(
'id, competition_id, member_id, category_id, participant_name, birth_date, address, phone, email, participated, total_catch_count, total_weight_g, total_length_cm, heaviest_catch_g, longest_catch_cm, created_at, competition_categories ( id, name )',
)
.eq('competition_id', competitionId)
.order('participant_name');
if (error) throw error;
return data ?? [];
},
async addParticipant(input: CreateCompetitionParticipantInput) {
const { data, error } = await client
.from('competition_participants')
.insert({
competition_id: input.competitionId,
member_id: input.memberId,
category_id: input.categoryId,
participant_name: input.participantName,
birth_date: input.birthDate,
address: input.address,
phone: input.phone,
email: input.email,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateParticipant(
participantId: string,
updates: {
participated?: boolean;
totalCatchCount?: number;
totalWeightG?: number;
totalLengthCm?: number;
heaviestCatchG?: number;
longestCatchCm?: number;
},
) {
const updateData: Record<string, unknown> = {};
if (updates.participated !== undefined)
updateData.participated = updates.participated;
if (updates.totalCatchCount !== undefined)
updateData.total_catch_count = updates.totalCatchCount;
if (updates.totalWeightG !== undefined)
updateData.total_weight_g = updates.totalWeightG;
if (updates.totalLengthCm !== undefined)
updateData.total_length_cm = updates.totalLengthCm;
if (updates.heaviestCatchG !== undefined)
updateData.heaviest_catch_g = updates.heaviestCatchG;
if (updates.longestCatchCm !== undefined)
updateData.longest_catch_cm = updates.longestCatchCm;
const { data, error } = await client
.from('competition_participants')
.update(updateData)
.eq('id', participantId)
.select()
.single();
if (error) throw error;
return data;
},
async removeParticipant(participantId: string) {
const { error } = await client
.from('competition_participants')
.delete()
.eq('id', participantId);
if (error) throw error;
},
async computeCompetitionResults(competitionId: string) {
// Get all participants
const { data: participants, error: pError } = await client
.from('competition_participants')
.select('id, participant_name, member_id, category_id')
.eq('competition_id', competitionId);
if (pError) throw pError;
if (!participants || participants.length === 0) return [];
// Get all catches for this competition
const { data: catches, error: cError } = await client
.from('catches')
.select('id, competition_participant_id, quantity, weight_g, length_cm')
.eq('competition_id', competitionId)
.eq('is_empty_entry', false);
if (cError) throw cError;
const catchesList = catches ?? [];
// Aggregate per participant
const results = participants.map((p) => {
const pCatches = catchesList.filter(
(c) => c.competition_participant_id === p.id,
);
const totalCount = pCatches.reduce(
(sum, c) => sum + (c.quantity ?? 0),
0,
);
const totalWeightG = pCatches.reduce(
(sum, c) => sum + (c.weight_g ?? 0),
0,
);
const totalLengthCm = pCatches.reduce(
(sum, c) => sum + (c.length_cm ?? 0),
0,
);
const heaviestG = pCatches.reduce(
(max, c) => Math.max(max, c.weight_g ?? 0),
0,
);
const longestCm = pCatches.reduce(
(max, c) => Math.max(max, c.length_cm ?? 0),
0,
);
return {
participantId: p.id,
participantName: p.participant_name,
memberId: p.member_id,
categoryId: p.category_id,
totalCatchCount: totalCount,
totalWeightG,
totalLengthCm,
heaviestCatchG: heaviestG,
longestCatchCm: longestCm,
};
});
// Update each participant's results in DB
for (const r of results) {
await client
.from('competition_participants')
.update({
participated: true,
total_catch_count: r.totalCatchCount,
total_weight_g: r.totalWeightG,
total_length_cm: r.totalLengthCm,
heaviest_catch_g: r.heaviestCatchG,
longest_catch_cm: r.longestCatchCm,
})
.eq('id', r.participantId);
}
// Return ranked results (by total weight descending as default)
return results.sort((a, b) => b.totalWeightG - a.totalWeightG);
},
// =====================================================
// Suppliers
// =====================================================
async listSuppliers(accountId: string) {
const { data, error } = await client
.from('fish_suppliers')
.select(
'id, name, contact_person, phone, email, address, notes, is_active, created_at, updated_at',
)
.eq('account_id', accountId)
.eq('is_active', true)
.order('name');
if (error) throw error;
return data ?? [];
},
async createSupplier(input: CreateSupplierInput) {
const { data, error } = await client
.from('fish_suppliers')
.insert({
account_id: input.accountId,
name: input.name,
contact_person: input.contactPerson,
phone: input.phone,
email: input.email,
address: input.address,
notes: input.notes,
is_active: input.isActive,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateSupplier(input: UpdateSupplierInput) {
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.contactPerson !== undefined)
updateData.contact_person = input.contactPerson;
if (input.phone !== undefined) updateData.phone = input.phone;
if (input.email !== undefined) updateData.email = input.email;
if (input.address !== undefined) updateData.address = input.address;
if (input.notes !== undefined) updateData.notes = input.notes;
if (input.isActive !== undefined)
updateData.is_active = input.isActive;
const { data, error } = await client
.from('fish_suppliers')
.update(updateData)
.eq('id', input.supplierId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteSupplier(supplierId: string) {
const { error } = await client
.from('fish_suppliers')
.delete()
.eq('id', supplierId);
if (error) throw error;
},
// =====================================================
// Dashboard
// =====================================================
async getDashboardStats(accountId: string) {
const currentYear = new Date().getFullYear();
const today = new Date().toISOString().split('T')[0]!;
const [
watersResult,
speciesResult,
leasesResult,
catchBooksResult,
competitionsResult,
stockingResult,
] = await Promise.all([
// Waters count (non-archived)
client
.from('waters')
.select('id', { count: 'exact', head: true })
.eq('account_id', accountId)
.eq('is_archived', false),
// Species count (active)
client
.from('fish_species')
.select('id', { count: 'exact', head: true })
.eq('account_id', accountId)
.eq('is_active', true),
// Active leases count
client
.from('fishing_leases')
.select('id', { count: 'exact', head: true })
.eq('account_id', accountId)
.eq('is_archived', false)
.or(`end_date.is.null,end_date.gte.${today}`),
// Pending catch books (status = 'offen' or 'eingereicht')
client
.from('catch_books')
.select('id', { count: 'exact', head: true })
.eq('account_id', accountId)
.in('status', ['offen', 'eingereicht']),
// Upcoming competitions (date >= today)
client
.from('competitions')
.select('id', { count: 'exact', head: true })
.eq('account_id', accountId)
.gte('competition_date', today),
// Total stocking cost for current year
client
.from('fish_stocking')
.select('cost_euros')
.eq('account_id', accountId)
.gte('stocking_date', `${currentYear}-01-01`)
.lte('stocking_date', `${currentYear}-12-31`),
]);
const stockingCostYtd = (stockingResult.data ?? []).reduce(
(sum, s) => sum + (Number(s.cost_euros) || 0),
0,
);
return {
watersCount: watersResult.count ?? 0,
speciesCount: speciesResult.count ?? 0,
activeLeasesCount: leasesResult.count ?? 0,
pendingCatchBooksCount: catchBooksResult.count ?? 0,
upcomingCompetitionsCount: competitionsResult.count ?? 0,
stockingCostYtd: Math.round(stockingCostYtd * 100) / 100,
};
},
};
}