chore: perauset customer project

This commit is contained in:
e2e
2026-07-11 08:34:30 +02:00
commit 0639909ec6
293 changed files with 28412 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
{
"name": "@perauset/components",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@mantine/hooks": "^9.2.1",
"@pikku/mantine": "^0.12.6",
"@pikku/react": "^0.12.5"
},
"devDependencies": {
"@types/react": "^19"
},
"peerDependencies": {
"react": "^19.2.5"
}
}

View File

@@ -0,0 +1,131 @@
import { Table, Text, Card, Stack, Group, Box } from '@pikku/mantine/core'
import { useMediaQuery } from '@mantine/hooks'
import { type ReactNode } from 'react'
import { asI18n, type I18nNode } from '@pikku/react'
export interface Column<T> {
key: string
label: I18nNode
render: (row: T) => ReactNode
}
interface DataTableProps<T> {
data: T[]
columns: Column<T>[]
emptyMessage?: string
rowKey: (row: T) => string
}
export function DataTable<T>({
data,
columns,
emptyMessage = 'No data found.',
rowKey,
}: DataTableProps<T>) {
const isMobile = useMediaQuery('(max-width: 768px)')
if (data.length === 0) {
return (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text size="sm" c="dimmed" ta="center">
{asI18n(emptyMessage)}
</Text>
</Card>
)
}
if (isMobile) {
return (
<Stack gap="sm">
{data.map((row) => (
<Card
key={rowKey(row)}
padding="md"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Stack gap="xs">
{columns.map((col) => (
<Group key={col.key} justify="space-between" wrap="nowrap" gap="sm">
<Text size="xs" c="dimmed" fw={600} style={{ minWidth: 80 }}>
{col.label}
</Text>
<Box style={{ textAlign: 'right' }}>{col.render(row)}</Box>
</Group>
))}
</Stack>
</Card>
))}
</Stack>
)
}
return (
<Card
padding={0}
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
overflow: 'hidden',
}}
>
<Table
highlightOnHover
horizontalSpacing="lg"
verticalSpacing="sm"
styles={{
thead: {
backgroundColor: '#f8f6f2',
borderBottom: '2px solid rgba(61, 43, 31, 0.1)',
},
th: {
padding: '12px 16px',
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.05em',
textTransform: 'uppercase' as const,
color: '#8b7d6b',
},
td: {
padding: '14px 16px',
borderBottom: '1px solid rgba(61, 43, 31, 0.06)',
},
tr: {
transition: 'background-color 0.15s ease',
},
}}
>
<Table.Thead>
<Table.Tr>
{columns.map((col) => (
<Table.Th key={col.key}>{col.label}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.map((row) => (
<Table.Tr key={rowKey(row)}>
{columns.map((col) => (
<Table.Td key={col.key}>{col.render(row)}</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)
}

View File

@@ -0,0 +1,3 @@
export { PageHeader } from './page-header.js'
export { StatusBadge } from './status-badge.js'
export { DataTable, type Column } from './data-table.js'

View File

@@ -0,0 +1,32 @@
import { Title, Text, Group, Stack } from '@pikku/mantine/core'
import { type ReactNode } from 'react'
import type { I18nNode } from '@pikku/react'
export function PageHeader({
title,
description,
action,
}: {
title: I18nNode
description?: I18nNode
action?: ReactNode
}) {
return (
<Group justify="space-between" align="flex-start">
<Stack gap={4}>
<Title
order={2}
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{title}
</Title>
{description && (
<Text size="sm" c="dimmed">
{description}
</Text>
)}
</Stack>
{action}
</Group>
)
}

View File

@@ -0,0 +1,37 @@
import { Badge } from '@pikku/mantine/core'
import { asI18n } from '@pikku/react'
const STATUS_COLORS: Record<string, string> = {
submitted: 'yellow',
pending: 'yellow',
under_review: 'orange',
approved: 'blue',
confirmed: 'blue',
checked_in: 'green',
active: 'green',
open: 'green',
planned: 'blue',
checked_out: 'gray',
completed: 'gray',
full: 'orange',
closed: 'gray',
rejected: 'red',
declined: 'red',
cancelled: 'red',
blocked: 'pink',
waitlisted: 'orange',
}
function formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
export function StatusBadge({ status }: { status: string }) {
const color = STATUS_COLORS[status] ?? 'gray'
return (
<Badge color={color} variant="light" size="sm">
{asI18n(formatStatus(status))}
</Badge>
)
}

View File

@@ -0,0 +1,20 @@
{
"name": "@perauset/functions-sdk",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
"./pikku/api.gen": "./src/pikku/api.gen.ts",
"./pikku/pikku-fetch.gen": "./src/pikku/pikku-fetch.gen.ts",
"./pikku/pikku-rpc.gen": "./src/pikku/pikku-rpc.gen.ts",
"./pikku/rpc-map.gen": "./src/pikku/rpc-map.gen.d.ts",
"./pikku/http-map.gen": "./src/pikku/http-map.gen.d.ts",
"./pikku/*": "./src/pikku/*"
},
"dependencies": {
"@perauset/functions": "workspace:*",
"@pikku/fetch": "^0.12.6",
"@pikku/react": "^0.12.5",
"@tanstack/react-query": "^5.90.10"
}
}

View File

@@ -0,0 +1,37 @@
{
"name": "@perauset/functions",
"version": "0.0.1",
"type": "module",
"private": true,
"imports": {
"#pikku": "./.pikku/pikku-types.gen.ts",
"#pikku/*.gen.js": "./.pikku/*.gen.ts",
"#pikku/*": "./.pikku/*"
},
"scripts": {
"tsc": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/openai": "^2.0.0",
"@ai-sdk/openai-compatible": "^2.0.50",
"@pikku/addon-console": "^0.12.26",
"@pikku/ai-vercel": "^0.12.7",
"@pikku/better-auth": "^0.12.16",
"@pikku/kysely": "^0.13.0",
"@pikku/kysely-sqlite": "^0.12.8",
"@pikku/schema-cfworker": "^0.12.4",
"ai": "^6.0.116",
"better-auth": "^1.6.19",
"kysely": "^0.29.2",
"zod": "^4.3.6"
},
"peerDependencies": {
"@pikku/core": "^0.12.57"
},
"devDependencies": {
"@pikku/core": "^0.12.57",
"@standard-schema/spec": "^1.1.0",
"@types/node": "^25",
"typescript": "^5.9"
}
}

View File

@@ -0,0 +1,149 @@
import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
// Stays
import { requestStay } from '../functions/stays/request-stay.function.js'
import { approveStayRequest } from '../functions/stays/approve-stay-request.function.js'
import { rejectStayRequest } from '../functions/stays/reject-stay-request.function.js'
import { createStayFromRequest } from '../functions/stays/create-stay-from-request.function.js'
import { checkInStay } from '../functions/stays/check-in-stay.function.js'
import { checkOutStay } from '../functions/stays/check-out-stay.function.js'
import { cancelStay } from '../functions/stays/cancel-stay.function.js'
import { listStayRequests } from '../functions/stays/list-stay-requests.function.js'
import { listStays } from '../functions/stays/list-stays.function.js'
// Rooms
import { createRoom } from '../functions/rooms/create-room.function.js'
import { updateRoom } from '../functions/rooms/update-room.function.js'
import { allocateRoom } from '../functions/rooms/allocate-room.function.js'
import { releaseRoom } from '../functions/rooms/release-room.function.js'
import { listRooms } from '../functions/rooms/list-rooms.function.js'
import { listRoomAllocations } from '../functions/rooms/list-room-allocations.function.js'
import { checkRoomAvailability } from '../functions/rooms/check-room-availability.function.js'
// Boats
import { createBoatRoute } from '../functions/boats/create-boat-route.function.js'
import { createBoat } from '../functions/boats/create-boat.function.js'
import { createBoatTrip } from '../functions/boats/create-boat-trip.function.js'
import { openBoatTrip } from '../functions/boats/open-boat-trip.function.js'
import { requestBoatSeat } from '../functions/boats/request-boat-seat.function.js'
import { confirmBoatRequest } from '../functions/boats/confirm-boat-request.function.js'
import { declineBoatRequest } from '../functions/boats/decline-boat-request.function.js'
import { cancelBoatTrip } from '../functions/boats/cancel-boat-trip.function.js'
import { completeBoatTrip } from '../functions/boats/complete-boat-trip.function.js'
import { updateBoat } from '../functions/boats/update-boat.function.js'
import { updateBoatRoute } from '../functions/boats/update-boat-route.function.js'
import { listBoatTrips } from '../functions/boats/list-boat-trips.function.js'
import { listBoatRequests } from '../functions/boats/list-boat-requests.function.js'
import { listBoatRoutes } from '../functions/boats/list-boat-routes.function.js'
import { listBoats } from '../functions/boats/list-boats.function.js'
import { getBoatManifest } from '../functions/boats/get-boat-manifest.function.js'
// Tasks
import { createTaskTemplate } from '../functions/tasks/create-task-template.function.js'
import { createTaskInstance } from '../functions/tasks/create-task-instance.function.js'
import { assignTask } from '../functions/tasks/assign-task.function.js'
import { assignTaskToRole } from '../functions/tasks/assign-task-to-role.function.js'
import { claimTask } from '../functions/tasks/claim-task.function.js'
import { startTask } from '../functions/tasks/start-task.function.js'
import { completeTask } from '../functions/tasks/complete-task.function.js'
import { markTaskBlocked } from '../functions/tasks/mark-task-blocked.function.js'
import { listTasks } from '../functions/tasks/list-tasks.function.js'
import { generateRecurringTasks } from '../functions/tasks/generate-recurring-tasks.function.js'
// Kitchen
import { createMealService } from '../functions/kitchen/create-meal-service.function.js'
import { listMealServices } from '../functions/kitchen/list-meal-services.function.js'
import { upsertDietaryProfile } from '../functions/kitchen/upsert-dietary-profile.function.js'
import { createRetreatMeal } from '../functions/kitchen/create-retreat-meal.function.js'
import { listRetreatMeals } from '../functions/kitchen/list-retreat-meals.function.js'
// Inventory
import { createInventoryItem } from '../functions/inventory/create-inventory-item.function.js'
import { updateInventoryItem } from '../functions/inventory/update-inventory-item.function.js'
import { listInventory } from '../functions/inventory/list-inventory.function.js'
import { adjustInventory } from '../functions/inventory/adjust-inventory.function.js'
import { submitStocktake } from '../functions/inventory/submit-stocktake.function.js'
import { listInventoryTransactions } from '../functions/inventory/list-inventory-transactions.function.js'
import { requestInventory } from '../functions/inventory/request-inventory.function.js'
import { checkLowStock } from '../functions/inventory/check-low-stock.function.js'
// Finance
import { createFinanceRecord } from '../functions/finance/create-finance-record.function.js'
import { listFinanceRecords } from '../functions/finance/list-finance-records.function.js'
import { updateFinanceRecord } from '../functions/finance/update-finance-record.function.js'
import { linkFinanceRecord } from '../functions/finance/link-finance-record.function.js'
import { listFinanceLinks } from '../functions/finance/list-finance-links.function.js'
import { getFinanceSummary } from '../functions/finance/get-finance-summary.function.js'
import { fetchExchangeRates } from '../functions/finance/fetch-exchange-rates.function.js'
import { getExchangeRates } from '../functions/finance/get-exchange-rates.function.js'
// Retreats
import { createRetreat } from '../functions/retreats/create-retreat.function.js'
import { updateRetreat } from '../functions/retreats/update-retreat.function.js'
import { addRetreatPerson } from '../functions/retreats/add-retreat-person.function.js'
import { publishRetreat } from '../functions/retreats/publish-retreat.function.js'
import { cancelRetreat } from '../functions/retreats/cancel-retreat.function.js'
import { addRetreatScheduleItem } from '../functions/retreats/add-retreat-schedule-item.function.js'
import { listRetreats } from '../functions/retreats/list-retreats.function.js'
import { listRetreatPersons } from '../functions/retreats/list-retreat-persons.function.js'
import { listRetreatSchedule } from '../functions/retreats/list-retreat-schedule.function.js'
import { notifyRetreatParticipants } from '../functions/retreats/notify-retreat-participants.function.js'
// Users & Notifications
import { listUsers } from '../functions/users/list-users.function.js'
import { getUser } from '../functions/users/get-user.function.js'
import { updateUserProfile } from '../functions/users/update-user-profile.function.js'
import { inviteUser } from '../functions/users/invite-user.function.js'
import { setUserRoles } from '../functions/roles/set-user-roles.function.js'
import { createNotification } from '../functions/notifications/create-notification.function.js'
import { listNotifications } from '../functions/notifications/list-notifications.function.js'
import { getDashboardStats } from '../functions/dashboard/get-dashboard-stats.function.js'
import { listAuditLog } from '../functions/audit/list-audit-log.function.js'
export const adminAgent = pikkuAIAgent({
name: 'admin-agent',
description: 'Full admin agent with access to all village operations tools and dynamic workflow creation',
goal:
'You are the Per Auset village admin assistant with full access to all systems. You can manage ' +
'stays, rooms, boats, tasks, kitchen, inventory, finance, retreats, users, and notifications. ' +
'You can also create dynamic workflows to automate multi-step operations. ' +
'Always be helpful, confirm destructive actions, and provide context about what you did.',
model: 'openai/gpt-5-nano',
tools: [
// Stays
requestStay, approveStayRequest, rejectStayRequest, createStayFromRequest,
checkInStay, checkOutStay, cancelStay, listStayRequests, listStays,
// Rooms
createRoom, updateRoom, allocateRoom, releaseRoom,
listRooms, listRoomAllocations, checkRoomAvailability,
// Boats
createBoatRoute, createBoat, createBoatTrip, openBoatTrip,
requestBoatSeat, confirmBoatRequest, declineBoatRequest,
cancelBoatTrip, completeBoatTrip, updateBoat, updateBoatRoute,
listBoatTrips, listBoatRequests, listBoatRoutes, listBoats, getBoatManifest,
// Tasks
createTaskTemplate, createTaskInstance, assignTask, assignTaskToRole,
claimTask, startTask, completeTask, markTaskBlocked,
listTasks, generateRecurringTasks,
// Kitchen
createMealService, listMealServices, upsertDietaryProfile,
createRetreatMeal, listRetreatMeals,
// Inventory
createInventoryItem, updateInventoryItem, listInventory,
adjustInventory, submitStocktake, listInventoryTransactions,
requestInventory, checkLowStock,
// Finance
createFinanceRecord, listFinanceRecords, updateFinanceRecord,
linkFinanceRecord, listFinanceLinks, getFinanceSummary,
fetchExchangeRates, getExchangeRates,
// Retreats
createRetreat, updateRetreat, addRetreatPerson, publishRetreat,
cancelRetreat, addRetreatScheduleItem, listRetreats,
listRetreatPersons, listRetreatSchedule, notifyRetreatParticipants,
// Users & System
listUsers, getUser, updateUserProfile, inviteUser, setUserRoles,
createNotification, listNotifications, getDashboardStats, listAuditLog,
],
maxSteps: 20,
toolChoice: 'auto',
})

View File

@@ -0,0 +1,35 @@
import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
import { createBoatRoute } from '../functions/boats/create-boat-route.function.js'
import { createBoat } from '../functions/boats/create-boat.function.js'
import { createBoatTrip } from '../functions/boats/create-boat-trip.function.js'
import { openBoatTrip } from '../functions/boats/open-boat-trip.function.js'
import { requestBoatSeat } from '../functions/boats/request-boat-seat.function.js'
import { confirmBoatRequest } from '../functions/boats/confirm-boat-request.function.js'
import { declineBoatRequest } from '../functions/boats/decline-boat-request.function.js'
import { cancelBoatTrip } from '../functions/boats/cancel-boat-trip.function.js'
import { completeBoatTrip } from '../functions/boats/complete-boat-trip.function.js'
import { updateBoat } from '../functions/boats/update-boat.function.js'
import { updateBoatRoute } from '../functions/boats/update-boat-route.function.js'
import { listBoatTrips } from '../functions/boats/list-boat-trips.function.js'
import { listBoatRequests } from '../functions/boats/list-boat-requests.function.js'
import { listBoatRoutes } from '../functions/boats/list-boat-routes.function.js'
import { listBoats } from '../functions/boats/list-boats.function.js'
import { getBoatManifest } from '../functions/boats/get-boat-manifest.function.js'
export const boatsAgent = pikkuAIAgent({
name: 'boats-agent',
description: 'Manages boats, routes, trips, and seat requests',
goal:
'You manage the boat transport system for Per Auset village. You can create and manage boats, ' +
'routes, and trips. You can open trips for booking, handle seat requests, view manifests, ' +
'and manage the fleet. The village is on an island so boats are essential for transport.',
model: 'openai/gpt-5-nano',
tools: [
createBoatRoute, createBoat, createBoatTrip, openBoatTrip,
requestBoatSeat, confirmBoatRequest, declineBoatRequest,
cancelBoatTrip, completeBoatTrip, updateBoat, updateBoatRoute,
listBoatTrips, listBoatRequests, listBoatRoutes, listBoats, getBoatManifest,
],
maxSteps: 10,
toolChoice: 'auto',
})

View File

@@ -0,0 +1,27 @@
import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
import { createFinanceRecord } from '../functions/finance/create-finance-record.function.js'
import { listFinanceRecords } from '../functions/finance/list-finance-records.function.js'
import { updateFinanceRecord } from '../functions/finance/update-finance-record.function.js'
import { linkFinanceRecord } from '../functions/finance/link-finance-record.function.js'
import { listFinanceLinks } from '../functions/finance/list-finance-links.function.js'
import { getFinanceSummary } from '../functions/finance/get-finance-summary.function.js'
import { fetchExchangeRates } from '../functions/finance/fetch-exchange-rates.function.js'
import { getExchangeRates } from '../functions/finance/get-exchange-rates.function.js'
export const financeAgent = pikkuAIAgent({
name: 'finance-agent',
description: 'Manages finances — expenses, income, exchange rates, and reporting',
goal:
'You manage finances for Per Auset village. You can create and track expenses, income, and ' +
'reimbursements. You can link finance records to stays, boats, retreats, and inventory. ' +
'You can view financial summaries and manage exchange rates (base currency is EGP). ' +
'Supported currencies: EUR, USD, GBP, EGP. All amounts are auto-converted to EGP.',
model: 'openai/gpt-5-nano',
tools: [
createFinanceRecord, listFinanceRecords, updateFinanceRecord,
linkFinanceRecord, listFinanceLinks, getFinanceSummary,
fetchExchangeRates, getExchangeRates,
],
maxSteps: 10,
toolChoice: 'auto',
})

View File

@@ -0,0 +1,26 @@
import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
import { createInventoryItem } from '../functions/inventory/create-inventory-item.function.js'
import { updateInventoryItem } from '../functions/inventory/update-inventory-item.function.js'
import { listInventory } from '../functions/inventory/list-inventory.function.js'
import { adjustInventory } from '../functions/inventory/adjust-inventory.function.js'
import { submitStocktake } from '../functions/inventory/submit-stocktake.function.js'
import { listInventoryTransactions } from '../functions/inventory/list-inventory-transactions.function.js'
import { requestInventory } from '../functions/inventory/request-inventory.function.js'
import { checkLowStock } from '../functions/inventory/check-low-stock.function.js'
export const inventoryAgent = pikkuAIAgent({
name: 'inventory-agent',
description: 'Manages inventory — items, stock levels, requests, and stocktakes',
goal:
'You manage inventory for Per Auset village. You can create and update items, adjust quantities, ' +
'run stocktakes, check for low stock, request items, and view transaction history. ' +
'Categories are kitchen, rooms, equipment, garden, and other. Always warn about low stock items.',
model: 'openai/gpt-5-nano',
tools: [
createInventoryItem, updateInventoryItem, listInventory,
adjustInventory, submitStocktake, listInventoryTransactions,
requestInventory, checkLowStock,
],
maxSteps: 10,
toolChoice: 'auto',
})

View File

@@ -0,0 +1,22 @@
import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
import { createMealService } from '../functions/kitchen/create-meal-service.function.js'
import { listMealServices } from '../functions/kitchen/list-meal-services.function.js'
import { upsertDietaryProfile } from '../functions/kitchen/upsert-dietary-profile.function.js'
import { createRetreatMeal } from '../functions/kitchen/create-retreat-meal.function.js'
import { listRetreatMeals } from '../functions/kitchen/list-retreat-meals.function.js'
export const kitchenAgent = pikkuAIAgent({
name: 'kitchen-agent',
description: 'Manages kitchen operations — meal planning, dietary profiles, and retreat meals',
goal:
'You manage the kitchen at Per Auset village. You can create and list meal services (breakfast, ' +
'lunch, dinner, snack), manage dietary profiles for guests, and plan meals for specific retreats. ' +
'When creating meals, always ask about the expected headcount and any menu ideas.',
model: 'openai/gpt-5-nano',
tools: [
createMealService, listMealServices, upsertDietaryProfile,
createRetreatMeal, listRetreatMeals,
],
maxSteps: 10,
toolChoice: 'auto',
})

View File

@@ -0,0 +1,29 @@
import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
import { createRetreat } from '../functions/retreats/create-retreat.function.js'
import { updateRetreat } from '../functions/retreats/update-retreat.function.js'
import { addRetreatPerson } from '../functions/retreats/add-retreat-person.function.js'
import { publishRetreat } from '../functions/retreats/publish-retreat.function.js'
import { cancelRetreat } from '../functions/retreats/cancel-retreat.function.js'
import { addRetreatScheduleItem } from '../functions/retreats/add-retreat-schedule-item.function.js'
import { listRetreats } from '../functions/retreats/list-retreats.function.js'
import { listRetreatPersons } from '../functions/retreats/list-retreat-persons.function.js'
import { listRetreatSchedule } from '../functions/retreats/list-retreat-schedule.function.js'
import { notifyRetreatParticipants } from '../functions/retreats/notify-retreat-participants.function.js'
export const retreatsAgent = pikkuAIAgent({
name: 'retreats-agent',
description: 'Manages retreats — creation, participants, schedules, and notifications',
goal:
'You manage retreats at Per Auset village. You can create retreats, add participants and ' +
'facilitators, build schedules with sessions, publish retreats to open registration, ' +
'send notifications to participants, and cancel retreats. Retreats have statuses: ' +
'draft → open (published) → active → completed.',
model: 'openai/gpt-5-nano',
tools: [
createRetreat, updateRetreat, addRetreatPerson, publishRetreat,
cancelRetreat, addRetreatScheduleItem, listRetreats,
listRetreatPersons, listRetreatSchedule, notifyRetreatParticipants,
],
maxSteps: 10,
toolChoice: 'auto',
})

View File

@@ -0,0 +1,24 @@
import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
import { createRoom } from '../functions/rooms/create-room.function.js'
import { updateRoom } from '../functions/rooms/update-room.function.js'
import { allocateRoom } from '../functions/rooms/allocate-room.function.js'
import { releaseRoom } from '../functions/rooms/release-room.function.js'
import { listRooms } from '../functions/rooms/list-rooms.function.js'
import { listRoomAllocations } from '../functions/rooms/list-room-allocations.function.js'
import { checkRoomAvailability } from '../functions/rooms/check-room-availability.function.js'
export const roomsAgent = pikkuAIAgent({
name: 'rooms-agent',
description: 'Manages rooms — availability, allocations, and room inventory',
goal:
'You manage rooms at Per Auset village. You can create and update rooms, check availability ' +
'for date ranges, allocate rooms to stays, release allocations, and view current allocations. ' +
'Room types: private, shared, dorm, facilitator, staff. Each room has a price per night in EUR.',
model: 'openai/gpt-5-nano',
tools: [
createRoom, updateRoom, allocateRoom, releaseRoom,
listRooms, listRoomAllocations, checkRoomAvailability,
],
maxSteps: 10,
toolChoice: 'auto',
})

View File

@@ -0,0 +1,35 @@
import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
import { staysAgent } from './stays.agent.js'
import { boatsAgent } from './boats.agent.js'
import { tasksAgent } from './tasks.agent.js'
import { kitchenAgent } from './kitchen.agent.js'
import { inventoryAgent } from './inventory.agent.js'
import { financeAgent } from './finance.agent.js'
import { retreatsAgent } from './retreats.agent.js'
import { roomsAgent } from './rooms.agent.js'
import { adminAgent } from './admin.agent.js'
export const routerAgent = pikkuAIAgent({
name: 'perauset-router',
description: 'Main entry point — routes requests to the appropriate domain agent',
goal:
'You are the Per Auset village assistant. Route user requests to the appropriate domain agent:\n\n' +
'- **stays-agent**: Guest stays, stay requests, check-in/out\n' +
'- **boats-agent**: Boat trips, routes, seat requests, fleet management\n' +
'- **tasks-agent**: Village tasks, assignments, templates, recurring tasks\n' +
'- **kitchen-agent**: Meal planning, dietary profiles, retreat meals\n' +
'- **inventory-agent**: Stock levels, items, stocktakes, low-stock alerts\n' +
'- **finance-agent**: Expenses, income, exchange rates, financial reports\n' +
'- **retreats-agent**: Retreat creation, participants, schedules, notifications\n' +
'- **rooms-agent**: Room availability, allocations, room management\n' +
'- **admin-agent**: Complex cross-domain operations, workflows, system administration\n\n' +
'If the request spans multiple domains or requires creating workflows, use the admin-agent. ' +
'Always greet users warmly — this is a community village, not a corporate system.',
model: 'openai/gpt-5-nano',
agents: [
staysAgent, boatsAgent, tasksAgent, kitchenAgent,
inventoryAgent, financeAgent, retreatsAgent, roomsAgent,
adminAgent,
],
maxSteps: 15,
})

View File

@@ -0,0 +1,26 @@
import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
import { requestStay } from '../functions/stays/request-stay.function.js'
import { approveStayRequest } from '../functions/stays/approve-stay-request.function.js'
import { rejectStayRequest } from '../functions/stays/reject-stay-request.function.js'
import { createStayFromRequest } from '../functions/stays/create-stay-from-request.function.js'
import { checkInStay } from '../functions/stays/check-in-stay.function.js'
import { checkOutStay } from '../functions/stays/check-out-stay.function.js'
import { cancelStay } from '../functions/stays/cancel-stay.function.js'
import { listStayRequests } from '../functions/stays/list-stay-requests.function.js'
import { listStays } from '../functions/stays/list-stays.function.js'
export const staysAgent = pikkuAIAgent({
name: 'stays-agent',
description: 'Manages guest stays — requests, approvals, check-in/out, and cancellations',
goal:
'You manage guest stays at the Per Auset village. You can list current stays and requests, ' +
'approve or reject stay requests, create stays from approved requests, check guests in and out, ' +
'and cancel stays. Always confirm destructive actions before executing them.',
model: 'openai/gpt-5-nano',
tools: [
requestStay, approveStayRequest, rejectStayRequest, createStayFromRequest,
checkInStay, checkOutStay, cancelStay, listStayRequests, listStays,
],
maxSteps: 10,
toolChoice: 'auto',
})

View File

@@ -0,0 +1,29 @@
import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
import { createTaskTemplate } from '../functions/tasks/create-task-template.function.js'
import { createTaskInstance } from '../functions/tasks/create-task-instance.function.js'
import { assignTask } from '../functions/tasks/assign-task.function.js'
import { assignTaskToRole } from '../functions/tasks/assign-task-to-role.function.js'
import { claimTask } from '../functions/tasks/claim-task.function.js'
import { startTask } from '../functions/tasks/start-task.function.js'
import { completeTask } from '../functions/tasks/complete-task.function.js'
import { markTaskBlocked } from '../functions/tasks/mark-task-blocked.function.js'
import { listTasks } from '../functions/tasks/list-tasks.function.js'
import { generateRecurringTasks } from '../functions/tasks/generate-recurring-tasks.function.js'
export const tasksAgent = pikkuAIAgent({
name: 'tasks-agent',
description: 'Manages village tasks — creation, assignment, tracking, and recurring tasks',
goal:
'You manage tasks for Per Auset village. You can create tasks, assign them to users or roles, ' +
'track progress (claim, start, complete, block), create templates for recurring tasks, and ' +
'generate recurring task instances. Categories include kitchen, maintenance, housekeeping, ' +
'operations, retreat, and transport.',
model: 'openai/gpt-5-nano',
tools: [
createTaskTemplate, createTaskInstance, assignTask, assignTaskToRole,
claimTask, startTask, completeTask, markTaskBlocked,
listTasks, generateRecurringTasks,
],
maxSteps: 10,
toolChoice: 'auto',
})

View File

@@ -0,0 +1,26 @@
import type {
CoreServices,
CoreSingletonServices,
CoreConfig,
CoreUserSession,
} from "@pikku/core"
import type { LogLevel } from "@pikku/core/services"
import type { Kysely } from "kysely"
import type { DB } from "#pikku/db/schema.js"
export interface UserSession extends CoreUserSession {
userId: string
memberRoles: string[]
}
export interface Config extends CoreConfig {
port: number
hostname: string
logLevel: LogLevel
}
export interface SingletonServices extends CoreSingletonServices<Config> {
kysely: Kysely<DB>
}
export interface Services extends CoreServices<SingletonServices> {}

View File

@@ -0,0 +1,20 @@
import { LogLevel } from "@pikku/core/services";
import { pikkuConfig } from "#pikku/pikku-types.gen.js";
export const createConfig = pikkuConfig(async () => ({
port: Number(process.env.PORT ?? 6002),
hostname: "0.0.0.0",
logLevel: LogLevel.info,
// Local SQLite db that `pikku db migrate|seed|reset` operate on; override with
// PIKKU_SQLITE_DB for an isolated e2e file. The deployed runtime ignores this
// and connects via DATABASE_URL (see services.ts).
sqliteDb: process.env.PIKKU_SQLITE_DB ?? ".pikku-runtime/dev.db",
db: process.env.DATABASE_URL ?? {
host: process.env.DB_HOST ?? '0.0.0.0',
port: Number(process.env.DB_PORT ?? 5432),
user: process.env.DB_USER ?? 'yasser',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'perauset',
},
}));

View File

@@ -0,0 +1,56 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListAuditLogInput = z.object({
tableName: z.string().optional().describe('Filter by table name'),
recordId: z.string().optional().describe('Filter by record ID'),
userId: z.string().optional().describe('Filter by user who made the change'),
action: z.string().optional().describe('Filter by action type (INSERT, UPDATE, DELETE)'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListAuditLogOutput = z.object({
items: z.array(z.object({
auditId: z.string().describe('Audit log entry ID'),
tableName: z.string().describe('Table that was modified'),
recordId: z.string().describe('ID of the modified record'),
action: z.string().describe('Type of action performed'),
changedFields: z.any().nullable().describe('Fields that were changed'),
userId: z.string().nullable().describe('User who made the change'),
occurredAt: z.string().describe('When the change occurred'),
})).describe('List of audit log entries'),
})
export const listAuditLog = pikkuFunc({
expose: true,
tags: ['audit_log.view'],
input: ListAuditLogInput,
output: ListAuditLogOutput,
func: async ({ kysely }, { tableName, recordId, userId, action, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('auditLog')
.select(['auditId', 'tableName', 'recordId', 'action', 'changedFields', 'userId', 'occurredAt'])
if (tableName) query = query.where('tableName', '=', tableName)
if (recordId) query = query.where('recordId', '=', recordId)
if (userId) query = query.where('userId', '=', userId)
if (action) query = query.where('action', '=', action)
const rows = await query
.orderBy('occurredAt', 'desc')
.limit(limit)
.offset(offset)
.execute()
return {
items: rows.map((r) => ({
...r,
occurredAt: new Date(r.occurredAt as any).toISOString(),
})),
}
},
})

View File

@@ -0,0 +1,58 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
import { validateTransition } from '../../lib/state-machine.js'
export const CancelBoatTripInput = z.object({
tripId: z.string().uuid().describe('Trip ID to cancel'),
reason: z.string().optional().describe('Reason for cancellation'),
})
export const CancelBoatTripOutput = z.object({
tripId: z.string().uuid().describe('Cancelled trip ID'),
status: z.string().describe('New trip status (cancelled)'),
affectedRequests: z.number().describe('Number of pending/confirmed requests on this trip'),
})
export const cancelBoatTrip = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['boats.manage'],
input: CancelBoatTripInput,
output: CancelBoatTripOutput,
func: async ({ kysely }, { tripId, reason }, { session }) => {
const trip = await kysely
.selectFrom('boatTrip')
.select(['tripId', 'status'])
.where('tripId', '=', tripId)
.executeTakeFirst()
if (!trip) throw new NotFoundError('Trip not found')
validateTransition('boat_trip', trip.status, 'cancelled')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('boatTrip')
.set({
status: 'cancelled',
notes: reason ? `Cancelled: ${reason}` : null,
updatedAt: new Date(),
updatedByUserId: session!.userId,
})
.where('tripId', '=', tripId)
.execute()
// Count affected requests (submitted, under_review, confirmed)
const countResult = await kysely
.selectFrom('boatReservationRequest')
.select(kysely.fn.countAll<number>().as('count'))
.where('tripId', '=', tripId)
.where('status', 'in', ['submitted', 'under_review', 'confirmed'])
.executeTakeFirstOrThrow()
return { tripId, status: 'cancelled', affectedRequests: Number(countResult.count) }
},
})

View File

@@ -0,0 +1,45 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
import { validateTransition } from '../../lib/state-machine.js'
export const CompleteBoatTripInput = z.object({
tripId: z.string().uuid().describe('ID of the trip to complete'),
})
export const CompleteBoatTripOutput = z.object({
tripId: z.string().describe('Trip ID'),
status: z.string().describe('Updated trip status'),
})
export const completeBoatTrip = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['boats.manage'],
input: CompleteBoatTripInput,
output: CompleteBoatTripOutput,
func: async ({ kysely }, { tripId }, { session }) => {
const trip = await kysely
.selectFrom('boatTrip')
.select(['tripId', 'status'])
.where('tripId', '=', tripId)
.executeTakeFirst()
if (!trip) {
throw new NotFoundError('Boat trip not found')
}
validateTransition('boat_trip', trip.status, 'completed')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('boatTrip')
.set({ status: 'completed', updatedByUserId: session!.userId, updatedAt: new Date() })
.where('tripId', '=', tripId)
.execute()
return { tripId, status: 'completed' }
},
})

View File

@@ -0,0 +1,87 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError, ConflictError } from '../../lib/errors.js'
import { validateTransition } from '../../lib/state-machine.js'
export const ConfirmBoatRequestInput = z.object({
requestId: z.string().uuid().describe('ID of the reservation request to confirm'),
reviewNotes: z.string().optional().describe('Notes from the reviewer'),
})
export const ConfirmBoatRequestOutput = z.object({
requestId: z.string().describe('Request ID'),
status: z.string().describe('Updated request status'),
entryId: z.string().describe('Created manifest entry ID'),
})
export const confirmBoatRequest = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['boats.manage'],
input: ConfirmBoatRequestInput,
output: ConfirmBoatRequestOutput,
func: async ({ kysely }, { requestId, reviewNotes }, { session }) => {
const request = await kysely
.selectFrom('boatReservationRequest')
.select(['requestId', 'status', 'tripId', 'userId', 'stayId', 'requestedSeats'])
.where('requestId', '=', requestId)
.executeTakeFirst()
if (!request) {
throw new NotFoundError('Reservation request not found')
}
validateTransition('boat_reservation_request', request.status, 'confirmed')
// Check capacity
const trip = await kysely
.selectFrom('boatTrip')
.innerJoin('boat', 'boat.boatId', 'boatTrip.boatId')
.select(['boatTrip.tripId', 'boat.passengerCapacity'])
.where('boatTrip.tripId', '=', request.tripId)
.executeTakeFirst()
if (trip) {
const { count } = await kysely
.selectFrom('boatManifestEntry')
.select(kysely.fn.countAll<number>().as('count'))
.where('tripId', '=', request.tripId)
.where('status', '=', 'confirmed')
.executeTakeFirstOrThrow()
if (Number(count) + request.requestedSeats > trip.passengerCapacity) {
throw new ConflictError('Insufficient capacity on this trip')
}
}
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('boatReservationRequest')
.set({
status: 'confirmed',
reviewNotes: reviewNotes ?? null,
reviewedByUserId: session!.userId,
reviewedAt: new Date(),
updatedAt: new Date(),
})
.where('requestId', '=', requestId)
.execute()
const entry = await kysely
.insertInto('boatManifestEntry')
.values({
tripId: request.tripId,
userId: request.userId,
reservationRequestId: requestId,
stayId: request.stayId,
confirmedByUserId: session!.userId,
status: 'confirmed',
})
.returning('entryId')
.executeTakeFirstOrThrow()
return { requestId, status: 'confirmed', entryId: entry.entryId }
},
})

View File

@@ -0,0 +1,36 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const CreateBoatRouteInput = z.object({
name: z.string().describe('Name of the boat route'),
origin: z.string().describe('Origin location'),
destination: z.string().describe('Destination location'),
})
export const CreateBoatRouteOutput = z.object({
routeId: z.string().describe('Created route ID'),
})
export const createBoatRoute = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['boats.manage'],
input: CreateBoatRouteInput,
output: CreateBoatRouteOutput,
func: async ({ kysely }, { name, origin, destination }, { session }) => {
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('boatRoute')
.values({
name,
origin,
destination,
})
.returning('routeId')
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,43 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const CreateBoatTripInput = z.object({
boatId: z.string().uuid().optional().describe('ID of the boat assigned to this trip'),
routeId: z.string().uuid().describe('ID of the route for this trip'),
scheduledDepartureAt: z.string().describe('Scheduled departure date/time'),
scheduledArrivalAt: z.string().optional().describe('Scheduled arrival date/time'),
notes: z.string().optional().describe('Additional notes about the trip'),
})
export const CreateBoatTripOutput = z.object({
tripId: z.string().describe('Created trip ID'),
status: z.string().describe('Trip status'),
})
export const createBoatTrip = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['boats.manage'],
input: CreateBoatTripInput,
output: CreateBoatTripOutput,
func: async ({ kysely }, { boatId, routeId, scheduledDepartureAt, scheduledArrivalAt, notes }, { session }) => {
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('boatTrip')
.values({
boatId: boatId ?? null,
routeId,
scheduledDepartureAt: new Date(scheduledDepartureAt),
scheduledArrivalAt: scheduledArrivalAt ? new Date(scheduledArrivalAt) : null,
notes: notes ?? null,
status: 'planned',
createdByUserId: session!.userId,
})
.returning(['tripId', 'status'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,38 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const CreateBoatInput = z.object({
name: z.string().describe('Name of the boat'),
passengerCapacity: z.number().int().positive().describe('Maximum passenger capacity'),
cargoCapacityKg: z.number().positive().optional().describe('Cargo capacity in kilograms'),
notes: z.string().optional().describe('Additional notes about the boat'),
})
export const CreateBoatOutput = z.object({
boatId: z.string().describe('Created boat ID'),
})
export const createBoat = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['boats.manage'],
input: CreateBoatInput,
output: CreateBoatOutput,
func: async ({ kysely }, { name, passengerCapacity, cargoCapacityKg, notes }, { session }) => {
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('boat')
.values({
name,
passengerCapacity,
cargoCapacityKg: cargoCapacityKg ?? null,
notes: notes ?? null,
})
.returning('boatId')
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,52 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
import { validateTransition } from '../../lib/state-machine.js'
export const DeclineBoatRequestInput = z.object({
requestId: z.string().uuid().describe('ID of the reservation request to decline'),
reviewNotes: z.string().optional().describe('Notes from the reviewer'),
})
export const DeclineBoatRequestOutput = z.object({
requestId: z.string().describe('Request ID'),
status: z.string().describe('Updated request status'),
})
export const declineBoatRequest = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['boats.manage'],
input: DeclineBoatRequestInput,
output: DeclineBoatRequestOutput,
func: async ({ kysely }, { requestId, reviewNotes }, { session }) => {
const request = await kysely
.selectFrom('boatReservationRequest')
.select(['requestId', 'status'])
.where('requestId', '=', requestId)
.executeTakeFirst()
if (!request) {
throw new NotFoundError('Reservation request not found')
}
validateTransition('boat_reservation_request', request.status, 'declined')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('boatReservationRequest')
.set({
status: 'declined',
reviewNotes: reviewNotes ?? null,
reviewedByUserId: session!.userId,
reviewedAt: new Date(),
updatedAt: new Date(),
})
.where('requestId', '=', requestId)
.execute()
return { requestId, status: 'declined' }
},
})

View File

@@ -0,0 +1,71 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const GetBoatManifestInput = z.object({
tripId: z.string().uuid().describe('Trip ID to get manifest for'),
})
export const GetBoatManifestOutput = z.object({
trip: z.object({
tripId: z.string().describe('Trip ID'),
routeId: z.string().describe('Route ID'),
status: z.string().describe('Trip status'),
scheduledDepartureAt: z.coerce.date().describe('Scheduled departure time'),
}).describe('Trip details'),
passengers: z.array(z.object({
requestId: z.string().describe('Reservation request ID'),
userId: z.string().describe('User ID'),
displayName: z.string().nullable().describe('User display name'),
email: z.string().nullable().describe('User email'),
requestedSeats: z.number().describe('Number of seats requested'),
cargoNotes: z.string().nullable().describe('Cargo notes'),
specialRequirements: z.string().nullable().describe('Special requirements'),
status: z.string().describe('Request status'),
})).describe('List of confirmed passengers'),
})
export const getBoatManifest = pikkuFunc({
expose: true,
tags: ['boats.view_all'],
input: GetBoatManifestInput,
output: GetBoatManifestOutput,
func: async ({ kysely }, { tripId }) => {
const trip = await kysely
.selectFrom('boatTrip')
.select(['tripId', 'routeId', 'status', 'scheduledDepartureAt'])
.where('tripId', '=', tripId)
.executeTakeFirstOrThrow()
const passengers = await kysely
.selectFrom('boatReservationRequest')
.leftJoin('user', 'user.id', 'boatReservationRequest.userId')
.select([
'boatReservationRequest.requestId',
'boatReservationRequest.userId',
'user.displayName',
'user.email',
'boatReservationRequest.requestedSeats',
'boatReservationRequest.cargoNotes',
'boatReservationRequest.specialRequirements',
'boatReservationRequest.status',
])
.where('boatReservationRequest.tripId', '=', tripId)
.where('boatReservationRequest.status', '=', 'confirmed')
.orderBy('boatReservationRequest.createdAt', 'asc')
.execute()
return {
trip,
passengers: passengers.map((p) => ({
requestId: p.requestId,
userId: p.userId,
displayName: p.displayName ?? null,
email: p.email ?? null,
requestedSeats: p.requestedSeats,
cargoNotes: p.cargoNotes,
specialRequirements: p.specialRequirements,
status: p.status,
})),
}
},
})

View File

@@ -0,0 +1,65 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListBoatRequestsInput = z.object({
tripId: z.string().uuid().optional().describe('Filter by trip ID'),
status: z.enum(['submitted', 'under_review', 'confirmed', 'declined', 'waitlisted', 'cancelled']).optional().describe('Filter by request status'),
userId: z.string().uuid().optional().describe('Filter by requesting user ID'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListBoatRequestsOutput = z.object({
items: z.array(z.object({
requestId: z.string().describe('Request ID'),
tripId: z.string().describe('Trip ID'),
userId: z.string().describe('Requesting user ID'),
status: z.string().describe('Request status'),
requestedSeats: z.number().describe('Number of seats requested'),
stayId: z.string().nullable().describe('Associated stay ID'),
retreatId: z.string().nullable().describe('Associated retreat ID'),
cargoNotes: z.string().nullable().describe('Cargo notes'),
specialRequirements: z.string().nullable().describe('Special requirements'),
priorityReason: z.string().nullable().describe('Priority reason'),
createdAt: z.coerce.date().describe('Request creation date'),
})).describe('List of boat reservation requests'),
})
export const listBoatRequests = pikkuFunc({
expose: true,
tags: ['boats.view_all'],
input: ListBoatRequestsInput,
output: ListBoatRequestsOutput,
func: async ({ kysely }, { tripId, status, userId, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('boatReservationRequest')
.select([
'requestId', 'tripId', 'userId', 'status', 'requestedSeats',
'stayId', 'retreatId', 'cargoNotes', 'specialRequirements',
'priorityReason', 'createdAt',
])
if (tripId) {
query = query.where('tripId', '=', tripId)
}
if (status) {
query = query.where('status', '=', status)
}
if (userId) {
query = query.where('userId', '=', userId)
}
const items = await query
.orderBy('createdAt', 'desc')
.limit(limit)
.offset(offset)
.execute()
return { items }
},
})

View File

@@ -0,0 +1,44 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListBoatRoutesInput = z.object({
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
isActive: z.coerce.boolean().optional().describe('Filter by active status'),
})
export const ListBoatRoutesOutput = z.object({
items: z.array(z.object({
routeId: z.string().describe('Route ID'),
name: z.string().describe('Route name'),
origin: z.string().describe('Origin location'),
destination: z.string().describe('Destination location'),
isActive: z.boolean().describe('Whether the route is active'),
})).describe('List of boat routes'),
})
export const listBoatRoutes = pikkuFunc({
expose: true,
tags: ['boats.view_all'],
input: ListBoatRoutesInput,
output: ListBoatRoutesOutput,
func: async ({ kysely }, { limit: rawLimit, offset: rawOffset, isActive }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('boatRoute')
.select(['routeId', 'name', 'origin', 'destination', 'isActive'])
.orderBy('name', 'asc')
.limit(limit)
.offset(offset)
if (isActive !== undefined) {
query = query.where('isActive', '=', isActive)
}
const items = await query.execute()
return { items }
},
})

View File

@@ -0,0 +1,58 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListBoatTripsInput = z.object({
status: z.enum(['planned', 'open', 'full', 'closed', 'completed', 'cancelled']).optional().describe('Filter by trip status'),
from: z.string().optional().describe('Filter trips departing on or after this date'),
to: z.string().optional().describe('Filter trips departing on or before this date'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListBoatTripsOutput = z.object({
items: z.array(z.object({
tripId: z.string().describe('Trip ID'),
boatId: z.string().nullable().describe('Boat ID'),
routeId: z.string().describe('Route ID'),
status: z.string().describe('Trip status'),
scheduledDepartureAt: z.coerce.date().describe('Scheduled departure'),
scheduledArrivalAt: z.coerce.date().nullable().describe('Scheduled arrival'),
notes: z.string().nullable().describe('Trip notes'),
createdByUserId: z.string().describe('Created by user ID'),
})).describe('List of boat trips'),
})
export const listBoatTrips = pikkuFunc({
expose: true,
tags: ['boats.view_all'],
input: ListBoatTripsInput,
output: ListBoatTripsOutput,
func: async ({ kysely }, { status, from, to, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('boatTrip')
.select(['tripId', 'boatId', 'routeId', 'status', 'scheduledDepartureAt', 'scheduledArrivalAt', 'notes', 'createdByUserId'])
if (status) {
query = query.where('status', '=', status)
}
if (from) {
query = query.where('scheduledDepartureAt', '>=', new Date(from))
}
if (to) {
query = query.where('scheduledDepartureAt', '<=', new Date(to))
}
const items = await query
.orderBy('scheduledDepartureAt', 'desc')
.limit(limit)
.offset(offset)
.execute()
return { items }
},
})

View File

@@ -0,0 +1,50 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListBoatsInput = z.object({
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
isActive: z.coerce.boolean().optional().describe('Filter by active status'),
})
export const ListBoatsOutput = z.object({
items: z.array(z.object({
boatId: z.string().describe('Boat ID'),
name: z.string().describe('Boat name'),
passengerCapacity: z.number().describe('Passenger capacity'),
cargoCapacityKg: z.number().nullable().describe('Cargo capacity in kilograms'),
isActive: z.boolean().describe('Whether the boat is active'),
notes: z.string().nullable().describe('Additional notes'),
})).describe('List of boats'),
})
export const listBoats = pikkuFunc({
expose: true,
tags: ['boats.view_all'],
input: ListBoatsInput,
output: ListBoatsOutput,
func: async ({ kysely }, { limit: rawLimit, offset: rawOffset, isActive }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('boat')
.select(['boatId', 'name', 'passengerCapacity', 'cargoCapacityKg', 'isActive', 'notes'])
.orderBy('name', 'asc')
.limit(limit)
.offset(offset)
if (isActive !== undefined) {
query = query.where('isActive', '=', isActive)
}
const rows = await query.execute()
const items = rows.map((row) => ({
...row,
cargoCapacityKg: row.cargoCapacityKg != null ? Number(row.cargoCapacityKg) : null,
}))
return { items }
},
})

View File

@@ -0,0 +1,45 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
import { validateTransition } from '../../lib/state-machine.js'
export const OpenBoatTripInput = z.object({
tripId: z.string().uuid().describe('ID of the trip to open'),
})
export const OpenBoatTripOutput = z.object({
tripId: z.string().describe('Trip ID'),
status: z.string().describe('Updated trip status'),
})
export const openBoatTrip = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['boats.manage'],
input: OpenBoatTripInput,
output: OpenBoatTripOutput,
func: async ({ kysely }, { tripId }, { session }) => {
const trip = await kysely
.selectFrom('boatTrip')
.select(['tripId', 'status'])
.where('tripId', '=', tripId)
.executeTakeFirst()
if (!trip) {
throw new NotFoundError('Boat trip not found')
}
validateTransition('boat_trip', trip.status, 'open')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('boatTrip')
.set({ status: 'open', updatedByUserId: session!.userId, updatedAt: new Date() })
.where('tripId', '=', tripId)
.execute()
return { tripId, status: 'open' }
},
})

View File

@@ -0,0 +1,62 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError, ConflictError } from '../../lib/errors.js'
export const RequestBoatSeatInput = z.object({
tripId: z.string().uuid().describe('ID of the trip to request a seat on'),
stayId: z.string().uuid().optional().describe('Associated stay ID'),
retreatId: z.string().uuid().optional().describe('Associated retreat ID'),
requestedSeats: z.number().int().positive().optional().default(1).describe('Number of seats requested'),
cargoNotes: z.string().optional().describe('Notes about cargo'),
specialRequirements: z.string().optional().describe('Special requirements for the trip'),
priorityReason: z.string().optional().describe('Reason for priority consideration'),
})
export const RequestBoatSeatOutput = z.object({
requestId: z.string().describe('Created request ID'),
status: z.string().describe('Request status'),
})
export const requestBoatSeat = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['boats.request'],
input: RequestBoatSeatInput,
output: RequestBoatSeatOutput,
func: async ({ kysely }, { tripId, stayId, retreatId, requestedSeats, cargoNotes, specialRequirements, priorityReason }, { session }) => {
const trip = await kysely
.selectFrom('boatTrip')
.select(['tripId', 'status'])
.where('tripId', '=', tripId)
.executeTakeFirst()
if (!trip) {
throw new NotFoundError('Boat trip not found')
}
if (trip.status !== 'open') {
throw new ConflictError('Trip is not open for seat requests')
}
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('boatReservationRequest')
.values({
tripId,
userId: session!.userId,
stayId: stayId ?? null,
retreatId: retreatId ?? null,
requestedSeats: requestedSeats ?? 1,
cargoNotes: cargoNotes ?? null,
specialRequirements: specialRequirements ?? null,
priorityReason: priorityReason ?? null,
status: 'submitted',
})
.returning(['requestId', 'status'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,53 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
export const UpdateBoatRouteInput = z.object({
routeId: z.string().uuid().describe('Route ID to update'),
name: z.string().optional().describe('Updated route name'),
origin: z.string().optional().describe('Updated origin location'),
destination: z.string().optional().describe('Updated destination location'),
isActive: z.boolean().optional().describe('Whether the route is active'),
})
export const UpdateBoatRouteOutput = z.object({
routeId: z.string().uuid().describe('Updated route ID'),
success: z.boolean().describe('Whether the update succeeded'),
})
export const updateBoatRoute = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['boats.manage'],
input: UpdateBoatRouteInput,
output: UpdateBoatRouteOutput,
func: async ({ kysely }, { routeId, ...fields }, { session }) => {
const route = await kysely
.selectFrom('boatRoute')
.select('routeId')
.where('routeId', '=', routeId)
.executeTakeFirst()
if (!route) throw new NotFoundError('Boat route not found')
await setAuditContext(kysely, session!.userId)
const updateFields: Record<string, any> = {}
if (fields.name !== undefined) updateFields.name = fields.name
if (fields.origin !== undefined) updateFields.origin = fields.origin
if (fields.destination !== undefined) updateFields.destination = fields.destination
if (fields.isActive !== undefined) updateFields.isActive = fields.isActive
updateFields.updatedAt = new Date()
if (Object.keys(updateFields).length > 1) {
await kysely
.updateTable('boatRoute')
.set(updateFields)
.where('routeId', '=', routeId)
.execute()
}
return { routeId, success: true }
},
})

View File

@@ -0,0 +1,58 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError, ConflictError } from '../../lib/errors.js'
export const UpdateBoatTripInput = z.object({
tripId: z.string().uuid().describe('Trip ID to update'),
boatId: z.string().uuid().optional().describe('Updated boat assignment'),
scheduledDepartureAt: z.string().optional().describe('Updated departure time (ISO 8601)'),
scheduledArrivalAt: z.string().optional().describe('Updated arrival time (ISO 8601)'),
notes: z.string().optional().describe('Updated trip notes'),
})
export const UpdateBoatTripOutput = z.object({
tripId: z.string().uuid().describe('Updated trip ID'),
success: z.boolean().describe('Whether the update succeeded'),
})
export const updateBoatTrip = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['boats.manage'],
input: UpdateBoatTripInput,
output: UpdateBoatTripOutput,
func: async ({ kysely }, { tripId, ...fields }, { session }) => {
const trip = await kysely
.selectFrom('boatTrip')
.select(['tripId', 'status'])
.where('tripId', '=', tripId)
.executeTakeFirst()
if (!trip) throw new NotFoundError('Trip not found')
if (['completed', 'cancelled'].includes(trip.status)) {
throw new ConflictError(`Cannot update trip in '${trip.status}' status`)
}
await setAuditContext(kysely, session!.userId)
const updateFields: Record<string, any> = {}
if (fields.boatId !== undefined) updateFields.boatId = fields.boatId
if (fields.scheduledDepartureAt !== undefined) updateFields.scheduledDepartureAt = new Date(fields.scheduledDepartureAt)
if (fields.scheduledArrivalAt !== undefined) updateFields.scheduledArrivalAt = new Date(fields.scheduledArrivalAt)
if (fields.notes !== undefined) updateFields.notes = fields.notes
updateFields.updatedAt = new Date()
updateFields.updatedByUserId = session!.userId
if (Object.keys(updateFields).length > 2) {
await kysely
.updateTable('boatTrip')
.set(updateFields)
.where('tripId', '=', tripId)
.execute()
}
return { tripId, success: true }
},
})

View File

@@ -0,0 +1,55 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
export const UpdateBoatInput = z.object({
boatId: z.string().uuid().describe('Boat ID to update'),
name: z.string().optional().describe('Updated boat name'),
passengerCapacity: z.number().int().positive().optional().describe('Updated passenger capacity'),
cargoCapacityKg: z.number().positive().optional().describe('Updated cargo capacity in kilograms'),
isActive: z.boolean().optional().describe('Whether the boat is active'),
notes: z.string().optional().describe('Updated notes about the boat'),
})
export const UpdateBoatOutput = z.object({
boatId: z.string().uuid().describe('Updated boat ID'),
success: z.boolean().describe('Whether the update succeeded'),
})
export const updateBoat = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['boats.manage'],
input: UpdateBoatInput,
output: UpdateBoatOutput,
func: async ({ kysely }, { boatId, ...fields }, { session }) => {
const boat = await kysely
.selectFrom('boat')
.select('boatId')
.where('boatId', '=', boatId)
.executeTakeFirst()
if (!boat) throw new NotFoundError('Boat not found')
await setAuditContext(kysely, session!.userId)
const updateFields: Record<string, any> = {}
if (fields.name !== undefined) updateFields.name = fields.name
if (fields.passengerCapacity !== undefined) updateFields.passengerCapacity = fields.passengerCapacity
if (fields.cargoCapacityKg !== undefined) updateFields.cargoCapacityKg = fields.cargoCapacityKg
if (fields.isActive !== undefined) updateFields.isActive = fields.isActive
if (fields.notes !== undefined) updateFields.notes = fields.notes
updateFields.updatedAt = new Date()
if (Object.keys(updateFields).length > 1) {
await kysely
.updateTable('boat')
.set(updateFields)
.where('boatId', '=', boatId)
.execute()
}
return { boatId, success: true }
},
})

View File

@@ -0,0 +1,105 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const GetDashboardStatsInput = z.object({}).describe('No input required')
export const GetDashboardStatsOutput = z.object({
activeStays: z.number().describe('Number of confirmed or checked-in stays'),
upcomingBoats: z.number().describe('Number of planned or open boat trips with future departure'),
openTasks: z.number().describe('Number of open, assigned, or in-progress tasks'),
unreadNotifications: z.number().describe('Number of unread notifications for current user'),
todayArrivals: z.array(z.object({
stayId: z.string(),
userId: z.string(),
stayType: z.string(),
startAt: z.string(),
})).describe('Stays starting today'),
todayDepartures: z.array(z.object({
stayId: z.string(),
userId: z.string(),
stayType: z.string(),
endAt: z.string(),
})).describe('Stays ending today'),
upcomingTasks: z.array(z.object({
taskId: z.string(),
title: z.string(),
category: z.string(),
status: z.string(),
scheduledStartAt: z.string().nullable(),
assignedRole: z.string().nullable(),
})).describe('Next 5 upcoming tasks'),
})
export const getDashboardStats = pikkuFunc({
expose: true,
input: GetDashboardStatsInput,
output: GetDashboardStatsOutput,
func: async ({ kysely }, _input, { session }) => {
const [staysResult, boatsResult, tasksResult, notificationsResult] = await Promise.all([
kysely
.selectFrom('stay')
.select(kysely.fn.countAll<number>().as('count'))
.where('status', 'in', ['confirmed', 'checked_in'])
.executeTakeFirstOrThrow(),
kysely
.selectFrom('boatTrip')
.select(kysely.fn.countAll<number>().as('count'))
.where('status', 'in', ['planned', 'open'])
.where('scheduledDepartureAt', '>', new Date())
.executeTakeFirstOrThrow(),
kysely
.selectFrom('taskInstance')
.select(kysely.fn.countAll<number>().as('count'))
.where('status', 'in', ['open', 'assigned', 'in_progress'])
.executeTakeFirstOrThrow(),
kysely
.selectFrom('notification')
.select(kysely.fn.countAll<number>().as('count'))
.where('userId', '=', session!.userId)
.where('readAt', 'is', null)
.executeTakeFirstOrThrow(),
])
const today = new Date().toISOString().split('T')[0]
const [arrivals, departures, upcomingTasks] = await Promise.all([
kysely
.selectFrom('stay')
.select(['stayId', 'userId', 'stayType', 'startAt'])
.where('status', 'in', ['confirmed', 'checked_in'])
.where(kysely.fn('date', ['startAt']), '=', today)
.execute(),
kysely
.selectFrom('stay')
.select(['stayId', 'userId', 'stayType', 'endAt'])
.where('status', 'in', ['confirmed', 'checked_in'])
.where(kysely.fn('date', ['endAt']), '=', today)
.execute(),
kysely
.selectFrom('taskInstance')
.select(['taskId', 'title', 'category', 'status', 'scheduledStartAt', 'assignedRole'])
.where('status', 'in', ['open', 'assigned', 'in_progress'])
.orderBy('scheduledStartAt', 'asc')
.limit(5)
.execute(),
])
return {
activeStays: Number(staysResult.count),
upcomingBoats: Number(boatsResult.count),
openTasks: Number(tasksResult.count),
unreadNotifications: Number(notificationsResult.count),
todayArrivals: arrivals.map(a => ({ ...a, stayType: a.stayType, startAt: String(a.startAt) })),
todayDepartures: departures.map(d => ({ ...d, stayType: d.stayType, endAt: String(d.endAt) })),
upcomingTasks: upcomingTasks.map(t => ({
...t,
scheduledStartAt: t.scheduledStartAt ? String(t.scheduledStartAt) : null,
})),
}
},
})

View File

@@ -0,0 +1,88 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
const RECORD_TYPES = ['expense', 'income', 'reimbursement'] as const
const CURRENCIES = ['EUR', 'USD', 'GBP', 'EGP'] as const
const FINANCE_CATEGORIES = ['operations', 'kitchen', 'maintenance', 'transport', 'retreat', 'housekeeping', 'other'] as const
export const CreateFinanceRecordInput = z.object({
name: z.string().describe('Record name / description summary'),
description: z.string().optional().describe('Detailed description'),
amount: z.number().positive().describe('Amount in the specified currency'),
currency: z.enum(CURRENCIES).optional().default('EUR').describe('Currency code'),
recordType: z.enum(RECORD_TYPES).describe('Type of finance record'),
purchasedByUserId: z.string().uuid().optional().describe('User who made the purchase'),
purchasedAt: z.string().optional().describe('Date of purchase (ISO string)'),
category: z.enum(FINANCE_CATEGORIES).describe('Expense category'),
})
export const CreateFinanceRecordOutput = z.object({
financeId: z.string().describe('Created finance record ID'),
name: z.string().describe('Record name'),
description: z.string().nullable().describe('Detailed description'),
amount: z.number().describe('Amount'),
amountEgp: z.number().nullable().describe('Amount in EGP'),
currency: z.string().describe('Currency code'),
recordType: z.string().describe('Record type'),
purchasedByUserId: z.string().nullable().describe('Purchaser user ID'),
purchasedAt: z.string().describe('Purchase date'),
category: z.string().describe('Category'),
createdByUserId: z.string().describe('Creator user ID'),
})
export const createFinanceRecord = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['finance.manage'],
input: CreateFinanceRecordInput,
output: CreateFinanceRecordOutput,
func: async ({ kysely }, { name, description, amount, currency, recordType, purchasedByUserId, purchasedAt, category }, { session }) => {
await setAuditContext(kysely, session!.userId)
const effectiveCurrency = currency ?? 'EUR'
// Calculate amount in EGP using latest exchange rate
let amountEgp: number | null = null
if (effectiveCurrency === 'EGP') {
amountEgp = amount
} else {
const rate = await kysely
.selectFrom('exchangeRate')
.select('rate')
.where('baseCurrency', '=', 'EGP')
.where('targetCurrency', '=', effectiveCurrency)
.orderBy('fetchedAt', 'desc')
.limit(1)
.executeTakeFirst()
if (rate) {
// rate is EGP→target, so to convert target→EGP: amount / rate
amountEgp = Math.round((amount / Number(rate.rate)) * 100) / 100
}
}
const result = await kysely
.insertInto('financeRecord')
.values({
name,
description: description ?? null,
amount,
amountEgp,
currency: effectiveCurrency,
recordType,
purchasedByUserId: purchasedByUserId ?? null,
purchasedAt: purchasedAt ? new Date(purchasedAt) : new Date(),
category,
createdByUserId: session!.userId,
})
.returning(['financeId', 'name', 'description', 'amount', 'amountEgp', 'currency', 'recordType', 'purchasedByUserId', 'purchasedAt', 'category', 'createdByUserId'])
.executeTakeFirstOrThrow()
return {
...result,
amount: Number(result.amount),
amountEgp: result.amountEgp != null ? Number(result.amountEgp) : null,
purchasedAt: new Date(result.purchasedAt as any).toISOString(),
}
},
})

View File

@@ -0,0 +1,76 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
const CURRENCIES = ['EUR', 'USD', 'GBP', 'EGP'] as const
const BASE_CURRENCY = 'EGP'
export const FetchExchangeRatesInput = z.object({
date: z.string().optional().describe('Date to fetch rates for (YYYY-MM-DD), defaults to today'),
})
export const FetchExchangeRatesOutput = z.object({
base: z.string().describe('Base currency'),
date: z.string().describe('Date of the rates'),
stored: z.number().describe('Number of rates stored'),
})
export const fetchExchangeRates = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['finance.manage'],
input: FetchExchangeRatesInput,
output: FetchExchangeRatesOutput,
func: async ({ kysely }, { date }) => {
const targetDate = date || new Date().toISOString().split('T')[0]
const targets = CURRENCIES.filter(c => c !== BASE_CURRENCY).join(',')
// Fetch from frankfurter.app (free, no API key)
const url = `https://api.frankfurter.app/${targetDate}?from=${BASE_CURRENCY}&to=${targets}`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch exchange rates: ${response.status} ${response.statusText}`)
}
const data = await response.json() as { base: string; date: string; rates: Record<string, number> }
// Store each rate
let stored = 0
for (const [currency, rate] of Object.entries(data.rates)) {
await kysely
.insertInto('exchangeRate')
.values({
baseCurrency: BASE_CURRENCY,
targetCurrency: currency,
rate,
fetchedAt: new Date(data.date),
})
.onConflict((oc) =>
oc.columns(['baseCurrency', 'targetCurrency', 'fetchedAt']).doUpdateSet({ rate })
)
.execute()
stored++
}
// Also store the identity rate (EGP → EGP = 1)
await kysely
.insertInto('exchangeRate')
.values({
baseCurrency: BASE_CURRENCY,
targetCurrency: BASE_CURRENCY,
rate: 1,
fetchedAt: new Date(data.date),
})
.onConflict((oc) =>
oc.columns(['baseCurrency', 'targetCurrency', 'fetchedAt']).doUpdateSet({ rate: 1 })
)
.execute()
stored++
return {
base: data.base,
date: data.date,
stored,
}
},
})

View File

@@ -0,0 +1,59 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const GetExchangeRatesInput = z.object({
date: z.string().optional().describe('Date to get rates for (YYYY-MM-DD), defaults to latest'),
})
export const GetExchangeRatesOutput = z.object({
base: z.string().describe('Base currency'),
date: z.string().describe('Date of the rates'),
rates: z.array(z.object({
targetCurrency: z.string(),
rate: z.number(),
})),
})
export const getExchangeRates = pikkuFunc({
expose: true,
tags: ['finance.view'],
input: GetExchangeRatesInput,
output: GetExchangeRatesOutput,
func: async ({ kysely }, { date }) => {
let query = kysely
.selectFrom('exchangeRate')
.select(['targetCurrency', 'rate', 'fetchedAt', 'baseCurrency'])
if (date) {
query = query.where('fetchedAt', '=', new Date(date))
} else {
const latest = await kysely
.selectFrom('exchangeRate')
.select('fetchedAt')
.orderBy('fetchedAt', 'desc')
.limit(1)
.executeTakeFirst()
if (!latest) {
return { base: 'EGP', date: new Date().toISOString().split('T')[0], rates: [] }
}
query = query.where('fetchedAt', '=', latest.fetchedAt)
}
const rates = await query.orderBy('targetCurrency', 'asc').execute()
const fetchedDate = rates[0]?.fetchedAt
const dateStr = fetchedDate instanceof Date
? fetchedDate.toISOString().split('T')[0]
: new Date().toISOString().split('T')[0]
return {
base: rates[0]?.baseCurrency ?? 'EGP',
date: dateStr,
rates: rates.map(r => ({
targetCurrency: r.targetCurrency,
rate: Number(r.rate),
})),
}
},
})

View File

@@ -0,0 +1,118 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const GetFinanceSummaryInput = z.object({
startDate: z.string().optional().describe('Start date filter (ISO 8601)'),
endDate: z.string().optional().describe('End date filter (ISO 8601)'),
})
export const GetFinanceSummaryOutput = z.object({
totalIncome: z.number().describe('Total income amount'),
totalExpenses: z.number().describe('Total expenses amount'),
totalReimbursements: z.number().describe('Total reimbursements amount'),
byCategory: z.array(z.object({
category: z.string().describe('Finance category'),
total: z.number().describe('Total amount in this category'),
})).describe('Breakdown by category'),
byMonth: z.array(z.object({
month: z.string().describe('Month in YYYY-MM format'),
income: z.number().describe('Income for this month'),
expenses: z.number().describe('Expenses for this month'),
})).describe('Monthly breakdown'),
})
export const getFinanceSummary = pikkuFunc({
expose: true,
tags: ['finance.view'],
input: GetFinanceSummaryInput,
output: GetFinanceSummaryOutput,
func: async ({ kysely }, { startDate, endDate }) => {
let baseQuery = kysely.selectFrom('financeRecord')
if (startDate) {
baseQuery = baseQuery.where('purchasedAt', '>=', new Date(startDate))
}
if (endDate) {
baseQuery = baseQuery.where('purchasedAt', '<=', new Date(endDate))
}
// Get totals by record type
const typeTotals = await baseQuery
.select(['recordType'])
.select(({ fn }) => fn.sum<number>('amount').as('total'))
.groupBy('recordType')
.execute()
let totalIncome = 0
let totalExpenses = 0
let totalReimbursements = 0
for (const row of typeTotals) {
const total = Number(row.total) || 0
if (row.recordType === 'income') totalIncome = total
else if (row.recordType === 'expense') totalExpenses = total
else if (row.recordType === 'reimbursement') totalReimbursements = total
}
// Get totals by category
let categoryQuery = kysely.selectFrom('financeRecord')
if (startDate) {
categoryQuery = categoryQuery.where('purchasedAt', '>=', new Date(startDate))
}
if (endDate) {
categoryQuery = categoryQuery.where('purchasedAt', '<=', new Date(endDate))
}
const categoryTotals = await categoryQuery
.select(['category'])
.select(({ fn }) => fn.sum<number>('amount').as('total'))
.groupBy('category')
.orderBy('category', 'asc')
.execute()
const byCategory = categoryTotals.map((row) => ({
category: row.category,
total: Number(row.total) || 0,
}))
// Get monthly breakdown - fetch raw records and aggregate in code
let monthQuery = kysely.selectFrom('financeRecord')
.select(['recordType', 'amount', 'purchasedAt'])
if (startDate) {
monthQuery = monthQuery.where('purchasedAt', '>=', new Date(startDate))
}
if (endDate) {
monthQuery = monthQuery.where('purchasedAt', '<=', new Date(endDate))
}
const records = await monthQuery.orderBy('purchasedAt', 'asc').execute()
const monthMap = new Map<string, { income: number; expenses: number }>()
for (const record of records) {
const date = new Date(record.purchasedAt as any)
const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
if (!monthMap.has(month)) {
monthMap.set(month, { income: 0, expenses: 0 })
}
const entry = monthMap.get(month)!
const amount = Number(record.amount) || 0
if (record.recordType === 'income') {
entry.income += amount
} else if (record.recordType === 'expense') {
entry.expenses += amount
}
}
const byMonth = Array.from(monthMap.entries())
.map(([month, data]) => ({ month, ...data }))
.sort((a, b) => a.month.localeCompare(b.month))
return {
totalIncome,
totalExpenses,
totalReimbursements,
byCategory,
byMonth,
}
},
})

View File

@@ -0,0 +1,54 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
const ENTITY_TYPES = ['inventory_item', 'inventory_request', 'stay', 'boat_trip', 'retreat', 'task', 'room'] as const
export const LinkFinanceRecordInput = z.object({
financeId: z.string().uuid().describe('Finance record ID to link'),
entityType: z.enum(ENTITY_TYPES).describe('Type of entity to link'),
entityId: z.string().uuid().describe('ID of the entity to link'),
notes: z.string().optional().describe('Optional notes about the link'),
})
export const LinkFinanceRecordOutput = z.object({
linkId: z.string().describe('Created link ID'),
financeId: z.string().describe('Finance record ID'),
entityType: z.string().describe('Entity type'),
entityId: z.string().describe('Entity ID'),
notes: z.string().nullable().describe('Link notes'),
})
export const linkFinanceRecord = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['finance.manage'],
input: LinkFinanceRecordInput,
output: LinkFinanceRecordOutput,
func: async ({ kysely }, { financeId, entityType, entityId, notes }, { session }) => {
// Verify the finance record exists
const existing = await kysely
.selectFrom('financeRecord')
.select('financeId')
.where('financeId', '=', financeId)
.executeTakeFirst()
if (!existing) throw new NotFoundError('Finance record not found')
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('financeLink')
.values({
financeId,
entityType,
entityId,
notes: notes ?? null,
})
.returning(['linkId', 'financeId', 'entityType', 'entityId', 'notes'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,89 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
const ENTITY_TYPES = ['inventory_item', 'inventory_request', 'stay', 'boat_trip', 'retreat', 'task', 'room'] as const
export const ListFinanceLinksInput = z.object({
financeId: z.string().uuid().optional().describe('Filter links by finance record ID'),
entityType: z.enum(ENTITY_TYPES).optional().describe('Filter by entity type'),
entityId: z.string().uuid().optional().describe('Filter by entity ID'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListFinanceLinksOutput = z.object({
items: z.array(z.object({
linkId: z.string().describe('Link ID'),
financeId: z.string().describe('Finance record ID'),
entityType: z.string().describe('Entity type'),
entityId: z.string().describe('Entity ID'),
notes: z.string().nullable().describe('Link notes'),
financeRecord: z.object({
name: z.string().describe('Record name'),
amount: z.number().describe('Amount'),
currency: z.string().describe('Currency code'),
recordType: z.string().describe('Record type'),
category: z.string().describe('Category'),
}).describe('Associated finance record details'),
})).describe('List of finance links'),
})
export const listFinanceLinks = pikkuFunc({
expose: true,
tags: ['finance.view'],
input: ListFinanceLinksInput,
output: ListFinanceLinksOutput,
func: async ({ kysely }, { financeId, entityType, entityId, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('financeLink')
.innerJoin('financeRecord', 'financeRecord.financeId', 'financeLink.financeId')
.select([
'financeLink.linkId',
'financeLink.financeId',
'financeLink.entityType',
'financeLink.entityId',
'financeLink.notes',
'financeRecord.name',
'financeRecord.amount',
'financeRecord.currency',
'financeRecord.recordType',
'financeRecord.category',
])
if (financeId) {
query = query.where('financeLink.financeId', '=', financeId)
}
if (entityType) {
query = query.where('financeLink.entityType', '=', entityType)
}
if (entityId) {
query = query.where('financeLink.entityId', '=', entityId)
}
const items = await query
.orderBy('financeLink.createdAt', 'desc')
.limit(limit)
.offset(offset)
.execute()
return {
items: items.map(i => ({
linkId: i.linkId,
financeId: i.financeId,
entityType: i.entityType,
entityId: i.entityId,
notes: i.notes,
financeRecord: {
name: i.name,
amount: Number(i.amount),
currency: i.currency,
recordType: i.recordType,
category: i.category,
},
})),
}
},
})

View File

@@ -0,0 +1,76 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
const RECORD_TYPES = ['expense', 'income', 'reimbursement'] as const
const FINANCE_CATEGORIES = ['operations', 'kitchen', 'maintenance', 'transport', 'retreat', 'housekeeping', 'other'] as const
export const ListFinanceRecordsInput = z.object({
category: z.enum(FINANCE_CATEGORIES).optional().describe('Filter by category'),
recordType: z.enum(RECORD_TYPES).optional().describe('Filter by record type'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListFinanceRecordsOutput = z.object({
items: z.array(z.object({
financeId: z.string().describe('Finance record ID'),
name: z.string().describe('Record name'),
description: z.string().nullable().describe('Detailed description'),
amount: z.number().describe('Amount'),
amountEgp: z.number().nullable().describe('Amount in EGP'),
currency: z.string().describe('Currency code'),
recordType: z.string().describe('Record type'),
purchasedByUserId: z.string().nullable().describe('Purchaser user ID'),
purchasedAt: z.string().describe('Purchase date'),
category: z.string().describe('Category'),
createdByUserId: z.string().describe('Creator user ID'),
})).describe('List of finance records'),
total: z.number().describe('Total count of matching records'),
})
export const listFinanceRecords = pikkuFunc({
expose: true,
tags: ['finance.view'],
input: ListFinanceRecordsInput,
output: ListFinanceRecordsOutput,
func: async ({ kysely }, { category, recordType, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('financeRecord')
.select(['financeId', 'name', 'description', 'amount', 'amountEgp', 'currency', 'recordType', 'purchasedByUserId', 'purchasedAt', 'category', 'createdByUserId'])
let countQuery = kysely
.selectFrom('financeRecord')
.select(({ fn }) => fn.countAll().as('count'))
if (category) {
query = query.where('category', '=', category)
countQuery = countQuery.where('category', '=', category)
}
if (recordType) {
query = query.where('recordType', '=', recordType)
countQuery = countQuery.where('recordType', '=', recordType)
}
const [items, countResult] = await Promise.all([
query
.orderBy('purchasedAt', 'desc')
.limit(limit)
.offset(offset)
.execute(),
countQuery.executeTakeFirstOrThrow(),
])
return {
items: items.map(i => ({
...i,
amount: Number(i.amount),
amountEgp: i.amountEgp != null ? Number(i.amountEgp) : null,
purchasedAt: new Date(i.purchasedAt as any).toISOString(),
})),
total: Number(countResult.count),
}
},
})

View File

@@ -0,0 +1,83 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
const RECORD_TYPES = ['expense', 'income', 'reimbursement'] as const
const CURRENCIES = ['EUR', 'USD', 'GBP', 'EGP'] as const
const FINANCE_CATEGORIES = ['operations', 'kitchen', 'maintenance', 'transport', 'retreat', 'housekeeping', 'other'] as const
export const UpdateFinanceRecordInput = z.object({
financeId: z.string().uuid().describe('Finance record ID to update'),
name: z.string().optional().describe('Updated record name'),
description: z.string().optional().describe('Updated description'),
amount: z.number().positive().optional().describe('Updated amount'),
currency: z.enum(CURRENCIES).optional().describe('Updated currency code'),
recordType: z.enum(RECORD_TYPES).optional().describe('Updated record type'),
purchasedByUserId: z.string().uuid().optional().describe('Updated purchaser'),
purchasedAt: z.string().optional().describe('Updated purchase date'),
category: z.enum(FINANCE_CATEGORIES).optional().describe('Updated category'),
})
export const UpdateFinanceRecordOutput = z.object({
financeId: z.string().describe('Updated finance record ID'),
name: z.string().describe('Record name'),
description: z.string().nullable().describe('Detailed description'),
amount: z.number().describe('Amount'),
currency: z.string().describe('Currency code'),
recordType: z.string().describe('Record type'),
purchasedByUserId: z.string().nullable().describe('Purchaser user ID'),
purchasedAt: z.string().describe('Purchase date'),
category: z.string().describe('Category'),
createdByUserId: z.string().describe('Creator user ID'),
})
export const updateFinanceRecord = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['finance.manage'],
input: UpdateFinanceRecordInput,
output: UpdateFinanceRecordOutput,
func: async ({ kysely }, { financeId, ...fields }, { session }) => {
const existing = await kysely
.selectFrom('financeRecord')
.select('financeId')
.where('financeId', '=', financeId)
.executeTakeFirst()
if (!existing) throw new NotFoundError('Finance record not found')
await setAuditContext(kysely, session!.userId)
const updateFields: Record<string, any> = {}
if (fields.name !== undefined) updateFields.name = fields.name
if (fields.description !== undefined) updateFields.description = fields.description
if (fields.amount !== undefined) updateFields.amount = fields.amount
if (fields.currency !== undefined) updateFields.currency = fields.currency
if (fields.recordType !== undefined) updateFields.recordType = fields.recordType
if (fields.purchasedByUserId !== undefined) updateFields.purchasedByUserId = fields.purchasedByUserId
if (fields.purchasedAt !== undefined) updateFields.purchasedAt = new Date(fields.purchasedAt)
if (fields.category !== undefined) updateFields.category = fields.category
updateFields.updatedAt = new Date()
if (Object.keys(updateFields).length > 1) {
await kysely
.updateTable('financeRecord')
.set(updateFields)
.where('financeId', '=', financeId)
.execute()
}
const result = await kysely
.selectFrom('financeRecord')
.select(['financeId', 'name', 'description', 'amount', 'currency', 'recordType', 'purchasedByUserId', 'purchasedAt', 'category', 'createdByUserId'])
.where('financeId', '=', financeId)
.executeTakeFirstOrThrow()
return {
...result,
amount: Number(result.amount),
purchasedAt: new Date(result.purchasedAt as any).toISOString(),
}
},
})

View File

@@ -0,0 +1,17 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku/pikku-types.gen.js'
export const HealthCheckOutput = z.object({
ok: z.boolean().describe('Whether the service is healthy'),
})
export const healthCheck = pikkuSessionlessFunc({
description: 'Returns service health status.',
tags: ['system'],
auth: false,
expose: true,
output: HealthCheckOutput,
func: async () => {
return { ok: true }
},
})

View File

@@ -0,0 +1,69 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
const ADJUSTMENT_REASONS = ['stocktake', 'adjustment', 'consumption', 'restock', 'request_fulfilled'] as const
export const AdjustInventoryInput = z.object({
itemId: z.string().uuid().describe('Inventory item ID to adjust'),
quantityChange: z.number().describe('Quantity change (positive or negative)'),
reason: z.enum(ADJUSTMENT_REASONS).describe('Reason for the adjustment'),
notes: z.string().optional().describe('Optional notes about the adjustment'),
})
export const AdjustInventoryOutput = z.object({
transactionId: z.string().describe('Created transaction ID'),
itemId: z.string().describe('Inventory item ID'),
quantityChange: z.number().describe('Quantity change applied'),
reason: z.string().describe('Reason for adjustment'),
newQuantity: z.number().describe('New current quantity after adjustment'),
})
export const adjustInventory = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['inventory.manage'],
input: AdjustInventoryInput,
output: AdjustInventoryOutput,
func: async ({ kysely }, { itemId, quantityChange, reason, notes }, { session }) => {
const item = await kysely
.selectFrom('inventoryItem')
.select(['itemId', 'currentQuantity'])
.where('itemId', '=', itemId)
.executeTakeFirst()
if (!item) throw new NotFoundError('Inventory item not found')
await setAuditContext(kysely, session!.userId)
const newQuantity = Number(item.currentQuantity) + quantityChange
await kysely
.updateTable('inventoryItem')
.set({
currentQuantity: newQuantity,
updatedAt: new Date(),
})
.where('itemId', '=', itemId)
.execute()
const transaction = await kysely
.insertInto('inventoryTransaction')
.values({
itemId,
quantityChange,
reason,
notes: notes ?? null,
createdByUserId: session!.userId,
})
.returning(['transactionId', 'itemId', 'quantityChange', 'reason'])
.executeTakeFirstOrThrow()
return {
...transaction,
quantityChange: Number(transaction.quantityChange),
newQuantity,
}
},
})

View File

@@ -0,0 +1,67 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const CheckLowStockInput = z.object({}).describe('No input required')
export const CheckLowStockOutput = z.object({
alertsSent: z.number().describe('Number of low-stock alerts created'),
})
export const checkLowStock = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['inventory.manage'],
input: CheckLowStockInput,
output: CheckLowStockOutput,
func: async ({ kysely }, _input, { session }) => {
// Find items where current_quantity <= minimum_quantity
const lowStockItems = await kysely
.selectFrom('inventoryItem')
.select(['itemId', 'name', 'currentQuantity', 'minimumQuantity', 'unit'])
.where('isActive', '=', true)
.whereRef('currentQuantity', '<=', 'minimumQuantity')
.execute()
if (lowStockItems.length === 0) {
return { alertsSent: 0 }
}
// Find users with inventory.manage permission by looking at member roles
// Users with admin or coordinator or kitchen_lead roles have inventory.manage
const users = await kysely
.selectFrom('user')
.select(['id as userId', 'memberRoles'])
.execute()
const INVENTORY_MANAGE_ROLES = ['admin', 'coordinator', 'kitchen_lead']
const targetUserIds = users
.filter(u => u.memberRoles.some(r => INVENTORY_MANAGE_ROLES.includes(r)))
.map(u => u.userId)
if (targetUserIds.length === 0) {
return { alertsSent: 0 }
}
let alertsSent = 0
for (const item of lowStockItems) {
for (const userId of targetUserIds) {
await kysely
.insertInto('notification')
.values({
userId,
type: 'low_stock',
title: `Low stock: ${item.name}`,
body: `${item.name} is at ${Number(item.currentQuantity)} ${item.unit} (minimum: ${Number(item.minimumQuantity)} ${item.unit})`,
entityType: 'inventory_item',
entityId: item.itemId,
})
.execute()
alertsSent++
}
}
return { alertsSent }
},
})

View File

@@ -0,0 +1,56 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
const INVENTORY_CATEGORIES = ['kitchen', 'rooms', 'equipment', 'garden', 'other'] as const
export const CreateInventoryItemInput = z.object({
name: z.string().describe('Item name'),
unit: z.string().describe('Unit of measurement'),
currentQuantity: z.number().nonnegative().describe('Current quantity in stock'),
minimumQuantity: z.number().nonnegative().describe('Minimum quantity threshold'),
category: z.enum(INVENTORY_CATEGORIES).describe('Top-level category'),
subcategory: z.string().optional().describe('Free-text subcategory (e.g. garden-tools, utensils)'),
})
export const CreateInventoryItemOutput = z.object({
itemId: z.string().describe('Created inventory item ID'),
name: z.string().describe('Item name'),
unit: z.string().describe('Unit of measurement'),
currentQuantity: z.number().describe('Current quantity in stock'),
minimumQuantity: z.number().describe('Minimum quantity threshold'),
isActive: z.boolean().describe('Whether the item is active'),
category: z.string().describe('Top-level category'),
subcategory: z.string().nullable().describe('Free-text subcategory'),
})
export const createInventoryItem = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['inventory.manage'],
input: CreateInventoryItemInput,
output: CreateInventoryItemOutput,
func: async ({ kysely }, { name, unit, currentQuantity, minimumQuantity, category, subcategory }, { session }) => {
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('inventoryItem')
.values({
name,
unit,
currentQuantity,
minimumQuantity,
category,
subcategory: subcategory ?? null,
isActive: true,
})
.returning(['itemId', 'name', 'unit', 'currentQuantity', 'minimumQuantity', 'isActive', 'category', 'subcategory'])
.executeTakeFirstOrThrow()
return {
...result,
currentQuantity: Number(result.currentQuantity),
minimumQuantity: Number(result.minimumQuantity),
}
},
})

View File

@@ -0,0 +1,60 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { sql } from 'kysely'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
import { validateTransition } from '../../lib/state-machine.js'
export const FulfillInventoryRequestInput = z.object({
requestId: z.string().uuid().describe('Inventory request ID to fulfill'),
})
export const FulfillInventoryRequestOutput = z.object({
requestId: z.string().describe('Fulfilled request ID'),
status: z.string().describe('New request status'),
})
export const fulfillInventoryRequest = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['inventory.manage'],
input: FulfillInventoryRequestInput,
output: FulfillInventoryRequestOutput,
func: async ({ kysely }, { requestId }, { session }) => {
const request = await kysely
.selectFrom('inventoryRequest')
.select(['requestId', 'status', 'itemId', 'quantity'])
.where('requestId', '=', requestId)
.executeTakeFirst()
if (!request) throw new NotFoundError('Inventory request not found')
validateTransition('inventory_request', request.status, 'fulfilled')
await setAuditContext(kysely, session!.userId)
// Update the request status
await kysely
.updateTable('inventoryRequest')
.set({
status: 'fulfilled',
updatedAt: new Date(),
})
.where('requestId', '=', requestId)
.execute()
// Decrement inventory item quantity if linked
if (request.itemId) {
await kysely
.updateTable('inventoryItem')
.set({
currentQuantity: sql`current_quantity - ${request.quantity}`,
updatedAt: new Date(),
})
.where('itemId', '=', request.itemId)
.execute()
}
return { requestId, status: 'fulfilled' }
},
})

View File

@@ -0,0 +1,55 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListInventoryTransactionsInput = z.object({
itemId: z.string().uuid().optional().describe('Filter by inventory item ID'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListInventoryTransactionsOutput = z.object({
items: z.array(z.object({
transactionId: z.string().describe('Transaction ID'),
itemId: z.string().describe('Inventory item ID'),
quantityChange: z.number().describe('Quantity change'),
reason: z.string().describe('Reason for the transaction'),
notes: z.string().nullable().describe('Optional notes'),
createdByUserId: z.string().describe('User who created the transaction'),
createdAt: z.coerce.date().describe('When the transaction was created'),
})).describe('List of inventory transactions'),
})
export const listInventoryTransactions = pikkuFunc({
expose: true,
tags: ['inventory.manage'],
input: ListInventoryTransactionsInput,
output: ListInventoryTransactionsOutput,
func: async ({ kysely }, { itemId, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('inventoryTransaction')
.select([
'transactionId', 'itemId', 'quantityChange',
'reason', 'notes', 'createdByUserId', 'createdAt',
])
if (itemId) {
query = query.where('itemId', '=', itemId)
}
const items = await query
.orderBy('createdAt', 'desc')
.limit(limit)
.offset(offset)
.execute()
return {
items: items.map(i => ({
...i,
quantityChange: Number(i.quantityChange),
})),
}
},
})

View File

@@ -0,0 +1,59 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListInventoryInput = z.object({
activeOnly: z.coerce.boolean().optional().default(true).describe('Filter to active items only'),
category: z.string().optional().describe('Filter by category'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListInventoryOutput = z.object({
items: z.array(z.object({
itemId: z.string().describe('Inventory item ID'),
name: z.string().describe('Item name'),
unit: z.string().describe('Unit of measurement'),
currentQuantity: z.number().describe('Current quantity in stock'),
minimumQuantity: z.number().describe('Minimum quantity threshold'),
isActive: z.boolean().describe('Whether the item is active'),
category: z.string().describe('Top-level category'),
subcategory: z.string().nullable().describe('Free-text subcategory'),
})).describe('List of inventory items'),
})
export const listInventory = pikkuFunc({
expose: true,
tags: ['inventory.manage'],
input: ListInventoryInput,
output: ListInventoryOutput,
func: async ({ kysely }, { activeOnly, category, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('inventoryItem')
.select(['itemId', 'name', 'unit', 'currentQuantity', 'minimumQuantity', 'isActive', 'category', 'subcategory'])
if (activeOnly) {
query = query.where('isActive', '=', true)
}
if (category) {
query = query.where('category', '=', category)
}
const items = await query
.orderBy('category', 'asc')
.orderBy('name', 'asc')
.limit(limit)
.offset(offset)
.execute()
return {
items: items.map(i => ({
...i,
currentQuantity: Number(i.currentQuantity),
minimumQuantity: Number(i.minimumQuantity),
})),
}
},
})

View File

@@ -0,0 +1,54 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const RequestInventoryInput = z.object({
itemId: z.string().uuid().optional().describe('Existing inventory item ID'),
itemName: z.string().optional().describe('Name of item being requested'),
quantity: z.number().positive().describe('Quantity requested'),
unit: z.string().describe('Unit of measurement'),
purpose: z.string().optional().describe('Purpose of the request'),
})
export const RequestInventoryOutput = z.object({
requestId: z.string().describe('Created inventory request ID'),
status: z.string().describe('Request status'),
})
export const requestInventory = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['inventory.request'],
input: RequestInventoryInput,
output: RequestInventoryOutput,
func: async ({ kysely }, { itemId, itemName, quantity, unit, purpose }, { session }) => {
await setAuditContext(kysely, session!.userId)
// If itemId is provided but no itemName, look up the item name
let resolvedItemName = itemName ?? ''
if (itemId && !itemName) {
const item = await kysely
.selectFrom('inventoryItem')
.select(['name'])
.where('itemId', '=', itemId)
.executeTakeFirst()
resolvedItemName = item?.name ?? 'Unknown item'
}
const result = await kysely
.insertInto('inventoryRequest')
.values({
requestedByUserId: session!.userId,
itemId: itemId ?? null,
itemName: resolvedItemName,
quantity,
unit,
purpose: purpose ?? null,
status: 'submitted',
})
.returning(['requestId', 'status'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,51 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
import { validateTransition } from '../../lib/state-machine.js'
export const ReviewInventoryRequestInput = z.object({
requestId: z.string().uuid().describe('Inventory request ID to review'),
status: z.enum(['approved', 'rejected']).describe('Review decision'),
reviewNotes: z.string().optional().describe('Notes about the review decision'),
})
export const ReviewInventoryRequestOutput = z.object({
requestId: z.string().describe('Reviewed request ID'),
status: z.string().describe('New request status'),
})
export const reviewInventoryRequest = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['inventory.manage'],
input: ReviewInventoryRequestInput,
output: ReviewInventoryRequestOutput,
func: async ({ kysely }, { requestId, status, reviewNotes }, { session }) => {
const request = await kysely
.selectFrom('inventoryRequest')
.select(['requestId', 'status'])
.where('requestId', '=', requestId)
.executeTakeFirst()
if (!request) throw new NotFoundError('Inventory request not found')
validateTransition('inventory_request', request.status, status)
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('inventoryRequest')
.set({
status,
reviewedByUserId: session!.userId,
reviewedAt: new Date(),
reviewNotes: reviewNotes ?? null,
updatedAt: new Date(),
})
.where('requestId', '=', requestId)
.execute()
return { requestId, status }
},
})

View File

@@ -0,0 +1,87 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
export const SubmitStocktakeInput = z.object({
items: z.array(z.object({
itemId: z.string().uuid().describe('Inventory item ID'),
actualQuantity: z.number().nonnegative().describe('Actual counted quantity'),
})).min(1).describe('List of items with their actual quantities'),
})
export const SubmitStocktakeOutput = z.object({
adjustedCount: z.number().describe('Number of items adjusted'),
transactions: z.array(z.object({
transactionId: z.string().describe('Transaction ID'),
itemId: z.string().describe('Item ID'),
quantityChange: z.number().describe('Quantity change applied'),
newQuantity: z.number().describe('New quantity after stocktake'),
})).describe('List of transactions created'),
})
export const submitStocktake = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['inventory.manage'],
input: SubmitStocktakeInput,
output: SubmitStocktakeOutput,
func: async ({ kysely }, { items }, { session }) => {
await setAuditContext(kysely, session!.userId)
const transactions: Array<{
transactionId: string
itemId: string
quantityChange: number
newQuantity: number
}> = []
for (const { itemId, actualQuantity } of items) {
const item = await kysely
.selectFrom('inventoryItem')
.select(['itemId', 'currentQuantity'])
.where('itemId', '=', itemId)
.executeTakeFirst()
if (!item) throw new NotFoundError(`Inventory item ${itemId} not found`)
const currentQty = Number(item.currentQuantity)
const diff = actualQuantity - currentQty
if (diff === 0) continue
await kysely
.updateTable('inventoryItem')
.set({
currentQuantity: actualQuantity,
updatedAt: new Date(),
})
.where('itemId', '=', itemId)
.execute()
const transaction = await kysely
.insertInto('inventoryTransaction')
.values({
itemId,
quantityChange: diff,
reason: 'stocktake',
notes: `Stocktake: adjusted from ${currentQty} to ${actualQuantity}`,
createdByUserId: session!.userId,
})
.returning(['transactionId', 'itemId', 'quantityChange'])
.executeTakeFirstOrThrow()
transactions.push({
transactionId: transaction.transactionId,
itemId: transaction.itemId,
quantityChange: Number(transaction.quantityChange),
newQuantity: actualQuantity,
})
}
return {
adjustedCount: transactions.length,
transactions,
}
},
})

View File

@@ -0,0 +1,77 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
const INVENTORY_CATEGORIES = ['kitchen', 'rooms', 'equipment', 'garden', 'other'] as const
export const UpdateInventoryItemInput = z.object({
itemId: z.string().uuid().describe('Inventory item ID to update'),
name: z.string().optional().describe('Updated item name'),
unit: z.string().optional().describe('Updated unit of measurement'),
currentQuantity: z.number().nonnegative().optional().describe('Updated current quantity'),
minimumQuantity: z.number().nonnegative().optional().describe('Updated minimum quantity threshold'),
isActive: z.boolean().optional().describe('Whether the item is active'),
category: z.enum(INVENTORY_CATEGORIES).optional().describe('Updated category'),
subcategory: z.string().optional().describe('Updated subcategory'),
})
export const UpdateInventoryItemOutput = z.object({
itemId: z.string().describe('Updated inventory item ID'),
name: z.string().describe('Item name'),
unit: z.string().describe('Unit of measurement'),
currentQuantity: z.number().describe('Current quantity in stock'),
minimumQuantity: z.number().describe('Minimum quantity threshold'),
isActive: z.boolean().describe('Whether the item is active'),
category: z.string().describe('Top-level category'),
subcategory: z.string().nullable().describe('Free-text subcategory'),
})
export const updateInventoryItem = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['inventory.manage'],
input: UpdateInventoryItemInput,
output: UpdateInventoryItemOutput,
func: async ({ kysely }, { itemId, ...fields }, { session }) => {
const item = await kysely
.selectFrom('inventoryItem')
.select('itemId')
.where('itemId', '=', itemId)
.executeTakeFirst()
if (!item) throw new NotFoundError('Inventory item not found')
await setAuditContext(kysely, session!.userId)
const updateFields: Record<string, any> = {}
if (fields.name !== undefined) updateFields.name = fields.name
if (fields.unit !== undefined) updateFields.unit = fields.unit
if (fields.currentQuantity !== undefined) updateFields.currentQuantity = fields.currentQuantity
if (fields.minimumQuantity !== undefined) updateFields.minimumQuantity = fields.minimumQuantity
if (fields.isActive !== undefined) updateFields.isActive = fields.isActive
if (fields.category !== undefined) updateFields.category = fields.category
if (fields.subcategory !== undefined) updateFields.subcategory = fields.subcategory
updateFields.updatedAt = new Date()
if (Object.keys(updateFields).length > 1) {
await kysely
.updateTable('inventoryItem')
.set(updateFields)
.where('itemId', '=', itemId)
.execute()
}
const result = await kysely
.selectFrom('inventoryItem')
.select(['itemId', 'name', 'unit', 'currentQuantity', 'minimumQuantity', 'isActive', 'category', 'subcategory'])
.where('itemId', '=', itemId)
.executeTakeFirstOrThrow()
return {
...result,
currentQuantity: Number(result.currentQuantity),
minimumQuantity: Number(result.minimumQuantity),
}
},
})

View File

@@ -0,0 +1,43 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const CreateMealServiceInput = z.object({
serviceType: z.enum(['breakfast', 'lunch', 'dinner', 'snack']).describe('Type of meal'),
serviceDate: z.string().describe('Date of the meal service (ISO 8601)'),
retreatId: z.string().uuid().optional().describe('Associated retreat ID'),
plannedHeadcount: z.number().int().positive().optional().describe('Expected number of diners'),
menuNotes: z.string().optional().describe('Menu notes or description'),
})
export const CreateMealServiceOutput = z.object({
serviceId: z.string().describe('Created meal service ID'),
status: z.string().describe('Meal service status'),
})
export const createMealService = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['kitchen.manage'],
input: CreateMealServiceInput,
output: CreateMealServiceOutput,
func: async ({ kysely }, { serviceType, serviceDate, retreatId, plannedHeadcount, menuNotes }, { session }) => {
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('mealService')
.values({
serviceType,
serviceDate: new Date(serviceDate),
retreatId: retreatId ?? null,
plannedHeadcount: plannedHeadcount ?? null,
menuNotes: menuNotes ?? null,
status: 'planned',
createdByUserId: session!.userId,
})
.returning(['serviceId', 'status'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,43 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const CreateRetreatMealInput = z.object({
retreatId: z.string().uuid().describe('Retreat ID to create meal for'),
serviceType: z.enum(['breakfast', 'lunch', 'dinner', 'snack']).describe('Type of meal'),
serviceDate: z.string().describe('Date of the meal service (ISO 8601)'),
plannedHeadcount: z.number().int().positive().optional().describe('Expected number of diners'),
menuNotes: z.string().optional().describe('Menu notes or description'),
})
export const CreateRetreatMealOutput = z.object({
serviceId: z.string().describe('Created meal service ID'),
status: z.string().describe('Meal service status'),
})
export const createRetreatMeal = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['retreats.manage'],
input: CreateRetreatMealInput,
output: CreateRetreatMealOutput,
func: async ({ kysely }, { retreatId, serviceType, serviceDate, plannedHeadcount, menuNotes }, { session }) => {
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('mealService')
.values({
serviceType,
serviceDate: new Date(serviceDate),
retreatId,
plannedHeadcount: plannedHeadcount ?? null,
menuNotes: menuNotes ?? null,
status: 'planned',
createdByUserId: session!.userId,
})
.returning(['serviceId', 'status'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,96 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const GenerateMealAttendanceInput = z.object({
mealServiceId: z.string().uuid().describe('Meal service ID to generate attendance for'),
})
export const GenerateMealAttendanceOutput = z.object({
generated: z.number().describe('Number of attendance records generated'),
})
export const generateMealAttendance = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['kitchen.manage'],
input: GenerateMealAttendanceInput,
output: GenerateMealAttendanceOutput,
func: async ({ kysely }, { mealServiceId }, { session }) => {
await setAuditContext(kysely, session!.userId)
// Get the meal service to find the service date
const mealService = await kysely
.selectFrom('mealService')
.select(['serviceId', 'serviceDate'])
.where('serviceId', '=', mealServiceId)
.executeTakeFirstOrThrow()
const serviceDate = new Date(mealService.serviceDate as any)
// Find all active stays that overlap the service date
const activeStays = await kysely
.selectFrom('stay')
.select(['stayId', 'userId'])
.where('status', 'in', ['confirmed', 'checked_in'])
.where('startAt', '<=', serviceDate)
.where('endAt', '>=', serviceDate)
.execute()
// Find all retreat persons for retreats active on that date
const retreatPersons = await kysely
.selectFrom('retreatPerson')
.innerJoin('retreat', 'retreat.retreatId', 'retreatPerson.retreatId')
.select(['retreatPerson.userId', 'retreatPerson.retreatPersonId'])
.where('retreat.status', 'in', ['published', 'active'])
.where('retreat.startAt', '<=', serviceDate)
.where('retreat.endAt', '>=', serviceDate)
.where('retreatPerson.attendanceStatus', 'in', ['registered', 'confirmed', 'checked_in'])
.where('retreatPerson.userId', 'is not', null)
.execute()
// Collect unique user IDs to insert
const userMap = new Map<string, { stayId: string | null; source: 'stay' | 'retreat' }>()
for (const stay of activeStays) {
if (!userMap.has(stay.userId)) {
userMap.set(stay.userId, { stayId: stay.stayId, source: 'stay' })
}
}
for (const rp of retreatPersons) {
if (rp.userId && !userMap.has(rp.userId)) {
userMap.set(rp.userId, { stayId: null, source: 'retreat' })
}
}
// Get existing attendance records to skip duplicates
const existing = await kysely
.selectFrom('mealAttendance')
.select('userId')
.where('mealServiceId', '=', mealServiceId)
.execute()
const existingUserIds = new Set(existing.map((e) => e.userId))
// Insert new attendance records
const toInsert = Array.from(userMap.entries())
.filter(([userId]) => !existingUserIds.has(userId))
.map(([userId, { stayId, source }]) => ({
mealServiceId,
userId,
stayId,
sourceType: source as 'stay' | 'retreat',
status: 'expected' as const,
}))
if (toInsert.length > 0) {
await kysely
.insertInto('mealAttendance')
.values(toInsert)
.execute()
}
return { generated: toInsert.length }
},
})

View File

@@ -0,0 +1,78 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const GetMealDietarySummaryInput = z.object({
mealServiceId: z.string().uuid().describe('Meal service ID to get dietary summary for'),
})
export const GetMealDietarySummaryOutput = z.object({
totalAttendees: z.number().describe('Total number of attendees'),
dietarySummary: z.array(z.object({
restriction: z.string().describe('Dietary restriction type'),
count: z.number().describe('Number of attendees with this restriction'),
})).describe('Breakdown of dietary restrictions'),
})
export const getMealDietarySummary = pikkuFunc({
expose: true,
tags: ['kitchen.view'],
input: GetMealDietarySummaryInput,
output: GetMealDietarySummaryOutput,
func: async ({ kysely }, { mealServiceId }) => {
// Get all attendees for this meal service
const attendees = await kysely
.selectFrom('mealAttendance')
.select('userId')
.where('mealServiceId', '=', mealServiceId)
.where('status', '!=', 'cancelled')
.execute()
const totalAttendees = attendees.length
if (totalAttendees === 0) {
return { totalAttendees: 0, dietarySummary: [] }
}
const userIds = attendees.map((a) => a.userId)
// Get dietary profiles for these users
const profiles = await kysely
.selectFrom('dietaryProfile')
.select([
'isVegan',
'isVegetarian',
'isGlutenFree',
'isDairyFree',
'hasNutAllergy',
])
.where('userId', 'in', userIds)
.execute()
// Aggregate restrictions
const restrictions: Record<string, number> = {}
for (const profile of profiles) {
if (profile.isVegan) {
restrictions['Vegan'] = (restrictions['Vegan'] ?? 0) + 1
}
if (profile.isVegetarian) {
restrictions['Vegetarian'] = (restrictions['Vegetarian'] ?? 0) + 1
}
if (profile.isGlutenFree) {
restrictions['Gluten-Free'] = (restrictions['Gluten-Free'] ?? 0) + 1
}
if (profile.isDairyFree) {
restrictions['Dairy-Free'] = (restrictions['Dairy-Free'] ?? 0) + 1
}
if (profile.hasNutAllergy) {
restrictions['Nut Allergy'] = (restrictions['Nut Allergy'] ?? 0) + 1
}
}
const dietarySummary = Object.entries(restrictions)
.map(([restriction, count]) => ({ restriction, count }))
.sort((a, b) => b.count - a.count)
return { totalAttendees, dietarySummary }
},
})

View File

@@ -0,0 +1,52 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListMealAttendanceInput = z.object({
mealServiceId: z.string().uuid().describe('Meal service ID to list attendance for'),
})
export const ListMealAttendanceOutput = z.object({
items: z.array(z.object({
attendanceId: z.string().describe('Attendance record ID'),
mealServiceId: z.string().describe('Meal service ID'),
userId: z.string().describe('User ID'),
stayId: z.string().nullable().describe('Associated stay ID'),
status: z.string().describe('Attendance status'),
dietaryNotes: z.string().nullable().describe('Dietary notes from the attendance record'),
})).describe('List of meal attendance records'),
headcount: z.number().describe('Total number of attendance records'),
})
export const listMealAttendance = pikkuFunc({
expose: true,
tags: ['kitchen.view'],
input: ListMealAttendanceInput,
output: ListMealAttendanceOutput,
func: async ({ kysely }, { mealServiceId }) => {
const items = await kysely
.selectFrom('mealAttendance')
.select([
'attendanceId',
'mealServiceId',
'userId',
'stayId',
'status',
'notes',
])
.where('mealServiceId', '=', mealServiceId)
.orderBy('createdAt', 'asc')
.execute()
return {
items: items.map((item) => ({
attendanceId: item.attendanceId,
mealServiceId: item.mealServiceId,
userId: item.userId,
stayId: item.stayId,
status: item.status,
dietaryNotes: item.notes,
})),
headcount: items.length,
}
},
})

View File

@@ -0,0 +1,54 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListMealServicesInput = z.object({
date: z.string().optional().describe('Filter by service date (ISO 8601)'),
mealType: z.enum(['breakfast', 'lunch', 'dinner', 'snack']).optional().describe('Filter by meal type'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListMealServicesOutput = z.object({
items: z.array(z.object({
serviceId: z.string().describe('Meal service ID'),
serviceType: z.string().describe('Type of meal'),
serviceDate: z.coerce.date().describe('Date of service'),
retreatId: z.string().nullable().describe('Associated retreat ID'),
plannedHeadcount: z.number().nullable().describe('Expected headcount'),
menuNotes: z.string().nullable().describe('Menu notes'),
status: z.string().describe('Meal service status'),
createdByUserId: z.string().describe('Created by user ID'),
})).describe('List of meal services'),
})
export const listMealServices = pikkuFunc({
expose: true,
tags: ['kitchen.view'],
input: ListMealServicesInput,
output: ListMealServicesOutput,
func: async ({ kysely }, { date, mealType, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('mealService')
.select(['serviceId', 'serviceType', 'serviceDate', 'retreatId', 'plannedHeadcount', 'menuNotes', 'status', 'createdByUserId'])
if (date) {
query = query.where('serviceDate', '=', new Date(date))
}
if (mealType) {
query = query.where('serviceType', '=', mealType)
}
const items = await query
.orderBy('serviceDate', 'desc')
.orderBy('serviceType', 'asc')
.limit(limit)
.offset(offset)
.execute()
return { items }
},
})

View File

@@ -0,0 +1,44 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListRetreatMealsInput = z.object({
retreatId: z.string().uuid().describe('Retreat ID to list meals for'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListRetreatMealsOutput = z.object({
items: z.array(z.object({
serviceId: z.string().describe('Meal service ID'),
serviceType: z.string().describe('Type of meal'),
serviceDate: z.coerce.date().describe('Date of service'),
retreatId: z.string().nullable().describe('Associated retreat ID'),
plannedHeadcount: z.number().nullable().describe('Expected headcount'),
menuNotes: z.string().nullable().describe('Menu notes'),
status: z.string().describe('Meal service status'),
createdByUserId: z.string().describe('Created by user ID'),
})).describe('List of retreat meal services'),
})
export const listRetreatMeals = pikkuFunc({
expose: true,
tags: ['retreats.view'],
input: ListRetreatMealsInput,
output: ListRetreatMealsOutput,
func: async ({ kysely }, { retreatId, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
const items = await kysely
.selectFrom('mealService')
.select(['serviceId', 'serviceType', 'serviceDate', 'retreatId', 'plannedHeadcount', 'menuNotes', 'status', 'createdByUserId'])
.where('retreatId', '=', retreatId)
.orderBy('serviceDate', 'asc')
.orderBy('serviceType', 'asc')
.limit(limit)
.offset(offset)
.execute()
return { items }
},
})

View File

@@ -0,0 +1,60 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const UpsertDietaryProfileInput = z.object({
isVegan: z.boolean().optional().describe('Whether the user is vegan'),
isVegetarian: z.boolean().optional().describe('Whether the user is vegetarian'),
isGlutenFree: z.boolean().optional().describe('Whether the user requires gluten-free'),
isDairyFree: z.boolean().optional().describe('Whether the user requires dairy-free'),
hasNutAllergy: z.boolean().optional().describe('Whether the user has a nut allergy'),
allergyNotes: z.string().optional().describe('Notes about allergies'),
otherRequirements: z.string().optional().describe('Other dietary requirements'),
dislikes: z.string().optional().describe('Food dislikes'),
})
export const UpsertDietaryProfileOutput = z.object({
profileId: z.string().describe('Dietary profile ID'),
})
export const upsertDietaryProfile = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['kitchen.update_dietary'],
input: UpsertDietaryProfileInput,
output: UpsertDietaryProfileOutput,
func: async ({ kysely }, input, { session }) => {
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('dietaryProfile')
.values({
userId: session!.userId,
isVegan: input.isVegan ?? false,
isVegetarian: input.isVegetarian ?? false,
isGlutenFree: input.isGlutenFree ?? false,
isDairyFree: input.isDairyFree ?? false,
hasNutAllergy: input.hasNutAllergy ?? false,
allergyNotes: input.allergyNotes ?? null,
otherRequirements: input.otherRequirements ?? null,
dislikes: input.dislikes ?? null,
})
.onConflict((oc) =>
oc.column('userId').doUpdateSet({
isVegan: input.isVegan ?? false,
isVegetarian: input.isVegetarian ?? false,
isGlutenFree: input.isGlutenFree ?? false,
isDairyFree: input.isDairyFree ?? false,
hasNutAllergy: input.hasNutAllergy ?? false,
allergyNotes: input.allergyNotes ?? null,
otherRequirements: input.otherRequirements ?? null,
dislikes: input.dislikes ?? null,
updatedAt: new Date(),
})
)
.returning(['profileId'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,43 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const CreateNotificationInput = z.object({
userIds: z.array(z.string().uuid()).min(1).describe('User IDs to notify'),
title: z.string().describe('Notification title'),
body: z.string().optional().describe('Notification body'),
entityType: z.string().optional().describe('Related entity type'),
entityId: z.string().uuid().optional().describe('Related entity ID'),
})
export const CreateNotificationOutput = z.object({
created: z.number().describe('Number of notifications created'),
})
export const createNotification = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['notifications.manage'],
input: CreateNotificationInput,
output: CreateNotificationOutput,
func: async ({ kysely }, { userIds, title, body, entityType, entityId }) => {
let created = 0
for (const userId of userIds) {
await kysely
.insertInto('notification')
.values({
userId,
type: 'manual',
title,
body: body ?? null,
entityType: entityType ?? null,
entityId: entityId ?? null,
})
.execute()
created++
}
return { created }
},
})

View File

@@ -0,0 +1,44 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListNotificationsInput = z.object({
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListNotificationsOutput = z.object({
items: z.array(z.object({
notificationId: z.string().describe('Notification ID'),
type: z.string().describe('Notification type'),
title: z.string().describe('Notification title'),
body: z.string().nullable().describe('Notification body'),
entityType: z.string().nullable().describe('Related entity type'),
entityId: z.string().nullable().describe('Related entity ID'),
readAt: z.coerce.date().nullable().describe('When the notification was read'),
createdAt: z.coerce.date().describe('When the notification was created'),
})).describe('List of notifications'),
})
export const listNotifications = pikkuFunc({
expose: true,
input: ListNotificationsInput,
output: ListNotificationsOutput,
func: async ({ kysely }, { limit: rawLimit, offset: rawOffset }, { session }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
const items = await kysely
.selectFrom('notification')
.select([
'notificationId', 'type', 'title', 'body',
'entityType', 'entityId', 'readAt', 'createdAt',
])
.where('userId', '=', session!.userId)
.orderBy('createdAt', 'desc')
.limit(limit)
.offset(offset)
.execute()
return { items }
},
})

View File

@@ -0,0 +1,42 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { NotFoundError, ForbiddenError } from '../../lib/errors.js'
export const MarkNotificationReadInput = z.object({
notificationId: z.string().uuid().describe('ID of the notification to mark as read'),
})
export const MarkNotificationReadOutput = z.object({
notificationId: z.string().describe('Notification ID'),
success: z.boolean().describe('Whether the operation succeeded'),
})
export const markNotificationRead = pikkuFunc({
expose: true,
approvalRequired: true,
input: MarkNotificationReadInput,
output: MarkNotificationReadOutput,
func: async ({ kysely }, { notificationId }, { session }) => {
const notification = await kysely
.selectFrom('notification')
.select(['notificationId', 'userId'])
.where('notificationId', '=', notificationId)
.executeTakeFirst()
if (!notification) {
throw new NotFoundError('Notification not found')
}
if (notification.userId !== session!.userId) {
throw new ForbiddenError('Cannot mark another user\'s notification as read')
}
await kysely
.updateTable('notification')
.set({ readAt: new Date() })
.where('notificationId', '=', notificationId)
.execute()
return { notificationId, success: true }
},
})

View File

@@ -0,0 +1,55 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
export const AddRetreatPersonInput = z.object({
retreatId: z.string().uuid().describe('Retreat ID to add person to'),
userId: z.string().uuid().optional().describe('User ID if registered user'),
fullName: z.string().optional().describe('Full name of person'),
email: z.string().email().optional().describe('Email address'),
phone: z.string().optional().describe('Phone number'),
roleType: z.enum(['participant', 'facilitator', 'assistant_facilitator', 'organizer', 'support_staff']).describe('Role in retreat'),
notes: z.string().optional().describe('Additional notes'),
})
export const AddRetreatPersonOutput = z.object({
retreatPersonId: z.string().describe('Created retreat person ID'),
attendanceStatus: z.string().describe('Attendance status'),
})
export const addRetreatPerson = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['retreats.manage'],
input: AddRetreatPersonInput,
output: AddRetreatPersonOutput,
func: async ({ kysely }, { retreatId, userId, fullName, email, phone, roleType, notes }, { session }) => {
const retreat = await kysely
.selectFrom('retreat')
.select(['retreatId'])
.where('retreatId', '=', retreatId)
.executeTakeFirst()
if (!retreat) throw new NotFoundError('Retreat not found')
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('retreatPerson')
.values({
retreatId,
userId: userId ?? null,
fullName: fullName ?? null,
email: email ?? null,
phone: phone ?? null,
roleType,
attendanceStatus: 'registered',
notes: notes ?? null,
})
.returning(['retreatPersonId', 'attendanceStatus'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,53 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
export const AddRetreatScheduleItemInput = z.object({
retreatId: z.string().uuid().describe('Retreat ID to add schedule item to'),
title: z.string().min(1).describe('Schedule item title'),
description: z.string().optional().describe('Schedule item description'),
startsAt: z.string().describe('Start time (ISO 8601)'),
endsAt: z.string().describe('End time (ISO 8601)'),
location: z.string().optional().describe('Location of the activity'),
leadRetreatPersonId: z.string().uuid().optional().describe('Lead person for this schedule item'),
})
export const AddRetreatScheduleItemOutput = z.object({
itemId: z.string().describe('Created schedule item ID'),
})
export const addRetreatScheduleItem = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['retreats.manage'],
input: AddRetreatScheduleItemInput,
output: AddRetreatScheduleItemOutput,
func: async ({ kysely }, { retreatId, title, description, startsAt, endsAt, location, leadRetreatPersonId }, { session }) => {
const retreat = await kysely
.selectFrom('retreat')
.select(['retreatId'])
.where('retreatId', '=', retreatId)
.executeTakeFirst()
if (!retreat) throw new NotFoundError('Retreat not found')
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('retreatScheduleItem')
.values({
retreatId,
title,
description: description ?? null,
startsAt: new Date(startsAt),
endsAt: new Date(endsAt),
location: location ?? null,
leadRetreatPersonId: leadRetreatPersonId ?? null,
})
.returning(['itemId'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,43 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
import { validateTransition } from '../../lib/state-machine.js'
export const CancelRetreatInput = z.object({
retreatId: z.string().uuid().describe('Retreat ID to cancel'),
})
export const CancelRetreatOutput = z.object({
retreatId: z.string().describe('Cancelled retreat ID'),
status: z.string().describe('New retreat status'),
})
export const cancelRetreat = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['retreats.manage'],
input: CancelRetreatInput,
output: CancelRetreatOutput,
func: async ({ kysely }, { retreatId }, { session }) => {
const retreat = await kysely
.selectFrom('retreat')
.select(['retreatId', 'status'])
.where('retreatId', '=', retreatId)
.executeTakeFirst()
if (!retreat) throw new NotFoundError('Retreat not found')
validateTransition('retreat', retreat.status, 'cancelled')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('retreat')
.set({ status: 'cancelled', updatedAt: new Date() })
.where('retreatId', '=', retreatId)
.execute()
return { retreatId, status: 'cancelled' }
},
})

View File

@@ -0,0 +1,45 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const CreateRetreatInput = z.object({
slug: z.string().min(1).describe('URL-friendly slug for the retreat'),
name: z.string().min(1).describe('Retreat name'),
description: z.string().optional().describe('Retreat description'),
startAt: z.string().describe('Start date (ISO 8601)'),
endAt: z.string().describe('End date (ISO 8601)'),
capacity: z.number().int().positive().optional().describe('Maximum number of participants'),
})
export const CreateRetreatOutput = z.object({
retreatId: z.string().describe('Created retreat ID'),
status: z.string().describe('Retreat status'),
})
export const createRetreat = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['retreats.manage'],
input: CreateRetreatInput,
output: CreateRetreatOutput,
func: async ({ kysely }, { slug, name, description, startAt, endAt, capacity }, { session }) => {
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('retreat')
.values({
slug,
name,
description: description ?? null,
startAt: new Date(startAt),
endAt: new Date(endAt),
capacity: capacity ?? null,
status: 'draft',
createdByUserId: session!.userId,
})
.returning(['retreatId', 'status'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,45 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListRetreatPersonsInput = z.object({
retreatId: z.string().uuid().describe('Retreat ID to list persons for'),
})
export const ListRetreatPersonsOutput = z.object({
items: z.array(z.object({
retreatPersonId: z.string().describe('Retreat person ID'),
retreatId: z.string().describe('Retreat ID'),
userId: z.string().nullable().describe('User ID if registered'),
fullName: z.string().nullable().describe('Full name'),
email: z.string().nullable().describe('Email address'),
roleType: z.string().describe('Role in retreat'),
attendanceStatus: z.string().describe('Attendance status'),
createdAt: z.coerce.date().describe('Created timestamp'),
})).describe('List of retreat persons'),
})
export const listRetreatPersons = pikkuFunc({
expose: true,
tags: ['retreats.view'],
input: ListRetreatPersonsInput,
output: ListRetreatPersonsOutput,
func: async ({ kysely }, { retreatId }) => {
const items = await kysely
.selectFrom('retreatPerson')
.select([
'retreatPersonId',
'retreatId',
'userId',
'fullName',
'email',
'roleType',
'attendanceStatus',
'createdAt',
])
.where('retreatId', '=', retreatId)
.orderBy('createdAt', 'asc')
.execute()
return { items }
},
})

View File

@@ -0,0 +1,45 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListRetreatScheduleInput = z.object({
retreatId: z.string().uuid().describe('Retreat ID to list schedule for'),
})
export const ListRetreatScheduleOutput = z.object({
items: z.array(z.object({
itemId: z.string().describe('Schedule item ID'),
retreatId: z.string().describe('Retreat ID'),
title: z.string().describe('Schedule item title'),
description: z.string().nullable().describe('Schedule item description'),
startsAt: z.coerce.date().describe('Start time'),
endsAt: z.coerce.date().describe('End time'),
location: z.string().nullable().describe('Location'),
leadRetreatPersonId: z.string().nullable().describe('Lead person ID'),
})).describe('List of retreat schedule items'),
})
export const listRetreatSchedule = pikkuFunc({
expose: true,
tags: ['retreats.view'],
input: ListRetreatScheduleInput,
output: ListRetreatScheduleOutput,
func: async ({ kysely }, { retreatId }) => {
const items = await kysely
.selectFrom('retreatScheduleItem')
.select([
'itemId',
'retreatId',
'title',
'description',
'startsAt',
'endsAt',
'location',
'leadRetreatPersonId',
])
.where('retreatId', '=', retreatId)
.orderBy('startsAt', 'asc')
.execute()
return { items }
},
})

View File

@@ -0,0 +1,75 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListRetreatsInput = z.object({
status: z.enum(['draft', 'published', 'active', 'completed', 'cancelled']).optional().describe('Filter by retreat status'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListRetreatsOutput = z.object({
items: z.array(z.object({
retreatId: z.string().describe('Retreat ID'),
slug: z.string().describe('Retreat slug'),
name: z.string().describe('Retreat name'),
description: z.string().nullable().describe('Retreat description'),
startAt: z.coerce.date().describe('Start date'),
endAt: z.coerce.date().describe('End date'),
status: z.string().describe('Retreat status'),
capacity: z.number().nullable().describe('Maximum capacity'),
createdByUserId: z.string().describe('Created by user ID'),
facilitatorName: z.string().nullable().describe('Lead facilitator name'),
})).describe('List of retreats'),
})
export const listRetreats = pikkuFunc({
expose: true,
tags: ['retreats.view'],
input: ListRetreatsInput,
output: ListRetreatsOutput,
func: async ({ kysely }, { status, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('retreat')
.select(['retreatId', 'slug', 'name', 'description', 'startAt', 'endAt', 'status', 'capacity', 'createdByUserId'])
if (status) {
query = query.where('status', '=', status)
}
const items = await query
.orderBy('startAt', 'desc')
.limit(limit)
.offset(offset)
.execute()
// Fetch facilitator names for each retreat
const retreatIds = items.map(r => r.retreatId)
const facilitators = retreatIds.length > 0
? await kysely
.selectFrom('retreatPerson')
.leftJoin('user', 'user.id', 'retreatPerson.userId')
.select(['retreatPerson.retreatId', 'user.name', 'user.displayName'])
.where('retreatPerson.retreatId', 'in', retreatIds)
.where('retreatPerson.roleType', '=', 'facilitator')
.execute()
: []
const facilitatorMap = new Map<string, string>()
for (const f of facilitators) {
const name = f.displayName ?? f.name ?? null
if (name && !facilitatorMap.has(f.retreatId)) {
facilitatorMap.set(f.retreatId, name)
}
}
return {
items: items.map(r => ({
...r,
facilitatorName: facilitatorMap.get(r.retreatId) ?? null,
})),
}
},
})

View File

@@ -0,0 +1,50 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const NotifyRetreatParticipantsInput = z.object({
retreatId: z.string().uuid().describe('Retreat ID to notify participants for'),
title: z.string().describe('Notification title'),
body: z.string().describe('Notification body'),
})
export const NotifyRetreatParticipantsOutput = z.object({
notified: z.number().describe('Number of participants notified'),
})
export const notifyRetreatParticipants = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['retreats.manage'],
input: NotifyRetreatParticipantsInput,
output: NotifyRetreatParticipantsOutput,
func: async ({ kysely }, { retreatId, title, body }) => {
const persons = await kysely
.selectFrom('retreatPerson')
.select(['userId'])
.where('retreatId', '=', retreatId)
.where('userId', 'is not', null)
.execute()
let notified = 0
for (const person of persons) {
if (!person.userId) continue
await kysely
.insertInto('notification')
.values({
userId: person.userId,
type: 'retreat',
title,
body,
entityType: 'retreat',
entityId: retreatId,
})
.execute()
notified++
}
return { notified }
},
})

View File

@@ -0,0 +1,43 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
import { validateTransition } from '../../lib/state-machine.js'
export const PublishRetreatInput = z.object({
retreatId: z.string().uuid().describe('Retreat ID to publish'),
})
export const PublishRetreatOutput = z.object({
retreatId: z.string().describe('Published retreat ID'),
status: z.string().describe('New retreat status'),
})
export const publishRetreat = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['retreats.manage'],
input: PublishRetreatInput,
output: PublishRetreatOutput,
func: async ({ kysely }, { retreatId }, { session }) => {
const retreat = await kysely
.selectFrom('retreat')
.select(['retreatId', 'status'])
.where('retreatId', '=', retreatId)
.executeTakeFirst()
if (!retreat) throw new NotFoundError('Retreat not found')
validateTransition('retreat', retreat.status, 'published')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('retreat')
.set({ status: 'published', updatedAt: new Date() })
.where('retreatId', '=', retreatId)
.execute()
return { retreatId, status: 'published' }
},
})

View File

@@ -0,0 +1,59 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError, ConflictError } from '../../lib/errors.js'
export const UpdateRetreatInput = z.object({
retreatId: z.string().uuid().describe('Retreat ID to update'),
name: z.string().min(1).optional().describe('Updated retreat name'),
description: z.string().optional().describe('Updated retreat description'),
startAt: z.string().optional().describe('Updated start date (ISO 8601)'),
endAt: z.string().optional().describe('Updated end date (ISO 8601)'),
capacity: z.number().int().positive().optional().describe('Updated capacity'),
})
export const UpdateRetreatOutput = z.object({
retreatId: z.string().describe('Updated retreat ID'),
success: z.boolean().describe('Whether the update succeeded'),
})
export const updateRetreat = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['retreats.manage'],
input: UpdateRetreatInput,
output: UpdateRetreatOutput,
func: async ({ kysely }, { retreatId, ...fields }, { session }) => {
const retreat = await kysely
.selectFrom('retreat')
.select(['retreatId', 'status'])
.where('retreatId', '=', retreatId)
.executeTakeFirst()
if (!retreat) throw new NotFoundError('Retreat not found')
if (retreat.status !== 'draft') {
throw new ConflictError(`Cannot update retreat in '${retreat.status}' status. Only draft retreats are editable.`)
}
await setAuditContext(kysely, session!.userId)
const updateFields: Record<string, any> = {}
if (fields.name !== undefined) updateFields.name = fields.name
if (fields.description !== undefined) updateFields.description = fields.description
if (fields.startAt !== undefined) updateFields.startAt = new Date(fields.startAt)
if (fields.endAt !== undefined) updateFields.endAt = new Date(fields.endAt)
if (fields.capacity !== undefined) updateFields.capacity = fields.capacity
updateFields.updatedAt = new Date()
if (Object.keys(updateFields).length > 1) {
await kysely
.updateTable('retreat')
.set(updateFields)
.where('retreatId', '=', retreatId)
.execute()
}
return { retreatId, success: true }
},
})

View File

@@ -0,0 +1,25 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { ROLE_PERMISSIONS } from '../../lib/roles.js'
export const ListRolesInput = z.object({})
export const ListRolesOutput = z.object({
roles: z.array(z.object({
key: z.string().describe('Role identifier'),
permissions: z.array(z.string()).describe('Permissions granted by this role'),
})).describe('All available roles and their permissions'),
})
export const listRoles = pikkuFunc({
expose: true,
input: ListRolesInput,
output: ListRolesOutput,
func: async () => {
const roles = Object.entries(ROLE_PERMISSIONS).map(([key, permissions]) => ({
key,
permissions,
}))
return { roles }
},
})

View File

@@ -0,0 +1,40 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { ROLE_PERMISSIONS } from '../../lib/roles.js'
export const SetUserRolesInput = z.object({
userId: z.string().uuid().describe('User to update roles for'),
memberRoles: z.array(z.string()).describe('Member roles to assign (replaces existing)'),
})
export const SetUserRolesOutput = z.object({
userId: z.string().uuid().describe('Updated user ID'),
memberRoles: z.array(z.string()).describe('New member roles'),
})
export const setUserRoles = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['roles.assign'],
input: SetUserRolesInput,
output: SetUserRolesOutput,
func: async ({ kysely }, { userId, memberRoles }, { session }) => {
const validRoles = Object.keys(ROLE_PERMISSIONS)
for (const role of memberRoles) {
if (!validRoles.includes(role)) {
throw new Error(`Invalid role: ${role}`)
}
}
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('user')
.set({ memberRoles, updatedAt: new Date() })
.where('id', '=', userId)
.execute()
return { userId, memberRoles }
},
})

View File

@@ -0,0 +1,74 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { ConflictError } from '../../lib/errors.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const AllocateRoomInput = z.object({
roomId: z.string().uuid().describe('Room to allocate'),
stayId: z.string().uuid().describe('Stay to allocate room to'),
startsAt: z.string().describe('Allocation start date'),
endsAt: z.string().describe('Allocation end date'),
allocationReason: z.string().optional().describe('Reason for allocation'),
})
export const AllocateRoomOutput = z.object({
allocationId: z.string().describe('Created allocation ID'),
status: z.string().describe('Allocation status'),
})
export const allocateRoom = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['rooms.manage'],
input: AllocateRoomInput,
output: AllocateRoomOutput,
func: async ({ kysely }, { roomId, stayId, startsAt, endsAt, allocationReason }, { session }) => {
const startDate = new Date(startsAt)
const endDate = new Date(endsAt)
// Check for overlapping active allocations
const overlappingAllocation = await kysely
.selectFrom('roomAllocation')
.select('allocationId')
.where('roomId', '=', roomId)
.where('status', 'in', ['planned', 'active'])
.where('startsAt', '<', endDate)
.where('endsAt', '>', startDate)
.executeTakeFirst()
if (overlappingAllocation) {
throw new ConflictError('Room has an overlapping allocation for the requested dates')
}
// Check for room blocks
const overlappingBlock = await kysely
.selectFrom('roomBlock')
.select('blockId')
.where('roomId', '=', roomId)
.where('startsAt', '<', endDate)
.where('endsAt', '>', startDate)
.executeTakeFirst()
if (overlappingBlock) {
throw new ConflictError('Room is blocked for the requested dates')
}
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('roomAllocation')
.values({
roomId,
stayId,
startsAt: startDate,
endsAt: endDate,
allocationReason: allocationReason ?? null,
status: 'planned',
createdByUserId: session!.userId,
})
.returning(['allocationId', 'status'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,69 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const CheckRoomAvailabilityInput = z.object({
startDate: z.string().describe('Start date (ISO 8601) for availability check'),
endDate: z.string().describe('End date (ISO 8601) for availability check'),
})
export const CheckRoomAvailabilityOutput = z.object({
rooms: z.array(z.object({
roomId: z.string().describe('Room ID'),
code: z.string().describe('Room code'),
name: z.string().describe('Room name'),
roomType: z.string().describe('Room type'),
capacity: z.number().describe('Room capacity'),
currentAllocations: z.number().describe('Number of overlapping allocations'),
available: z.boolean().describe('Whether the room has availability'),
})).describe('List of rooms with availability info'),
})
export const checkRoomAvailability = pikkuFunc({
expose: true,
tags: ['rooms.view_all'],
input: CheckRoomAvailabilityInput,
output: CheckRoomAvailabilityOutput,
func: async ({ kysely }, { startDate, endDate }) => {
const start = new Date(startDate)
const end = new Date(endDate)
// Get all active rooms
const rooms = await kysely
.selectFrom('room')
.select(['roomId', 'code', 'name', 'roomType', 'capacity'])
.where('isActive', '=', true)
.orderBy('code', 'asc')
.execute()
// Get overlapping allocations for the date range
const allocations = await kysely
.selectFrom('roomAllocation')
.select(['roomId'])
.select(kysely.fn.countAll<number>().as('count'))
.where('status', 'in', ['planned', 'active'])
.where('startsAt', '<', end)
.where('endsAt', '>', start)
.groupBy('roomId')
.execute()
const allocationMap = new Map<string, number>()
for (const a of allocations) {
allocationMap.set(a.roomId, Number(a.count))
}
return {
rooms: rooms.map((room) => {
const currentAllocations = allocationMap.get(room.roomId) ?? 0
return {
roomId: room.roomId,
code: room.code,
name: room.name,
roomType: room.roomType,
capacity: room.capacity,
currentAllocations,
available: currentAllocations < room.capacity,
}
}),
}
},
})

View File

@@ -0,0 +1,42 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const CreateRoomInput = z.object({
code: z.string().describe('Unique room code'),
name: z.string().describe('Room name'),
roomType: z.enum(['dorm', 'facilitator', 'private', 'shared', 'staff']).describe('Room type'),
capacity: z.number().describe('Room capacity'),
pricePerNight: z.number().nonnegative().optional().describe('Price per night in EUR'),
notes: z.string().optional().describe('Additional notes'),
})
export const CreateRoomOutput = z.object({
roomId: z.string().describe('Created room ID'),
})
export const createRoom = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['rooms.manage'],
input: CreateRoomInput,
output: CreateRoomOutput,
func: async ({ kysely }, { code, name, roomType, capacity, pricePerNight, notes }, { session }) => {
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('room')
.values({
code,
name,
roomType,
capacity,
pricePerNight: pricePerNight ?? null,
notes: notes ?? null,
})
.returning('roomId')
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,58 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListRoomAllocationsInput = z.object({
roomId: z.string().uuid().optional().describe('Filter by room ID'),
stayId: z.string().uuid().optional().describe('Filter by stay ID'),
status: z.enum(['planned', 'active', 'completed', 'cancelled']).optional().describe('Filter by allocation status'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListRoomAllocationsOutput = z.object({
items: z.array(z.object({
allocationId: z.string().describe('Allocation ID'),
roomId: z.string().describe('Room ID'),
stayId: z.string().describe('Stay ID'),
startsAt: z.coerce.date().describe('Allocation start date'),
endsAt: z.coerce.date().describe('Allocation end date'),
status: z.string().describe('Allocation status'),
allocationReason: z.string().nullable().describe('Reason for allocation'),
createdByUserId: z.string().describe('Created by user ID'),
createdAt: z.coerce.date().describe('Created timestamp'),
})).describe('List of room allocations'),
})
export const listRoomAllocations = pikkuFunc({
expose: true,
tags: ['rooms.view_all'],
input: ListRoomAllocationsInput,
output: ListRoomAllocationsOutput,
func: async ({ kysely }, { roomId, stayId, status, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('roomAllocation')
.select([
'allocationId', 'roomId', 'stayId', 'startsAt', 'endsAt',
'status', 'allocationReason', 'createdByUserId', 'createdAt',
])
if (roomId) {
query = query.where('roomId', '=', roomId)
}
if (stayId) {
query = query.where('stayId', '=', stayId)
}
if (status) {
query = query.where('status', '=', status)
}
const items = await query.orderBy('startsAt', 'desc').limit(limit).offset(offset).execute()
return { items }
},
})

View File

@@ -0,0 +1,50 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListRoomsInput = z.object({
isActive: z.coerce.boolean().optional().describe('Filter by active status'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListRoomsOutput = z.object({
items: z.array(z.object({
roomId: z.string().describe('Room ID'),
code: z.string().describe('Room code'),
name: z.string().describe('Room name'),
roomType: z.string().describe('Room type'),
capacity: z.number().describe('Room capacity'),
pricePerNight: z.number().nullable().describe('Price per night in EUR'),
isActive: z.boolean().describe('Whether room is active'),
notes: z.string().nullable().describe('Additional notes'),
createdAt: z.coerce.date().describe('Created timestamp'),
})).describe('List of rooms'),
})
export const listRooms = pikkuFunc({
expose: true,
tags: ['rooms.view_all'],
input: ListRoomsInput,
output: ListRoomsOutput,
func: async ({ kysely }, { isActive, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('room')
.select(['roomId', 'code', 'name', 'roomType', 'capacity', 'pricePerNight', 'isActive', 'notes', 'createdAt'])
if (isActive !== undefined) {
query = query.where('isActive', '=', isActive)
}
const items = await query.orderBy('name', 'asc').limit(limit).offset(offset).execute()
return {
items: items.map(r => ({
...r,
pricePerNight: r.pricePerNight != null ? Number(r.pricePerNight) : null,
})),
}
},
})

View File

@@ -0,0 +1,49 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { NotFoundError } from '../../lib/errors.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { validateTransition } from '../../lib/state-machine.js'
export const ReleaseRoomInput = z.object({
allocationId: z.string().uuid().describe('Room allocation ID to release'),
})
export const ReleaseRoomOutput = z.object({
allocationId: z.string().describe('Released allocation ID'),
status: z.string().describe('New allocation status'),
})
export const releaseRoom = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['rooms.manage'],
input: ReleaseRoomInput,
output: ReleaseRoomOutput,
func: async ({ kysely }, { allocationId }, { session }) => {
const allocation = await kysely
.selectFrom('roomAllocation')
.select(['allocationId', 'status'])
.where('allocationId', '=', allocationId)
.executeTakeFirst()
if (!allocation) {
throw new NotFoundError('Room allocation not found')
}
validateTransition('room_allocation', allocation.status, 'completed')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('roomAllocation')
.set({
status: 'completed',
updatedByUserId: session!.userId,
updatedAt: new Date(),
})
.where('allocationId', '=', allocationId)
.execute()
return { allocationId, status: 'completed' }
},
})

View File

@@ -0,0 +1,57 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
export const UpdateRoomInput = z.object({
roomId: z.string().uuid().describe('Room ID to update'),
name: z.string().optional().describe('Updated room name'),
roomType: z.string().optional().describe('Updated room type'),
capacity: z.number().int().positive().optional().describe('Updated room capacity'),
pricePerNight: z.number().nonnegative().optional().describe('Price per night in EUR'),
isActive: z.boolean().optional().describe('Whether the room is active'),
notes: z.string().optional().describe('Updated room notes'),
})
export const UpdateRoomOutput = z.object({
roomId: z.string().uuid().describe('Updated room ID'),
success: z.boolean().describe('Whether the update succeeded'),
})
export const updateRoom = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['rooms.manage'],
input: UpdateRoomInput,
output: UpdateRoomOutput,
func: async ({ kysely }, { roomId, ...fields }, { session }) => {
const room = await kysely
.selectFrom('room')
.select('roomId')
.where('roomId', '=', roomId)
.executeTakeFirst()
if (!room) throw new NotFoundError('Room not found')
await setAuditContext(kysely, session!.userId)
const updateFields: Record<string, any> = {}
if (fields.name !== undefined) updateFields.name = fields.name
if (fields.roomType !== undefined) updateFields.roomType = fields.roomType
if (fields.capacity !== undefined) updateFields.capacity = fields.capacity
if (fields.pricePerNight !== undefined) updateFields.pricePerNight = fields.pricePerNight
if (fields.isActive !== undefined) updateFields.isActive = fields.isActive
if (fields.notes !== undefined) updateFields.notes = fields.notes
updateFields.updatedAt = new Date()
if (Object.keys(updateFields).length > 1) {
await kysely
.updateTable('room')
.set(updateFields)
.where('roomId', '=', roomId)
.execute()
}
return { roomId, success: true }
},
})

View File

@@ -0,0 +1,52 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { NotFoundError } from '../../lib/errors.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { validateTransition } from '../../lib/state-machine.js'
export const ApproveStayRequestInput = z.object({
requestId: z.string().uuid().describe('Stay request ID to approve'),
reviewNotes: z.string().optional().describe('Optional review notes'),
})
export const ApproveStayRequestOutput = z.object({
requestId: z.string().describe('Approved request ID'),
status: z.string().describe('New request status'),
})
export const approveStayRequest = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['stays.review'],
input: ApproveStayRequestInput,
output: ApproveStayRequestOutput,
func: async ({ kysely }, { requestId, reviewNotes }, { session }) => {
const request = await kysely
.selectFrom('stayRequest')
.select(['requestId', 'status'])
.where('requestId', '=', requestId)
.executeTakeFirst()
if (!request) {
throw new NotFoundError('Stay request not found')
}
validateTransition('stay_request', request.status, 'approved')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('stayRequest')
.set({
status: 'approved',
reviewedByUserId: session!.userId,
reviewedAt: new Date(),
reviewNotes: reviewNotes ?? null,
updatedAt: new Date(),
})
.where('requestId', '=', requestId)
.execute()
return { requestId, status: 'approved' }
},
})

View File

@@ -0,0 +1,50 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { NotFoundError } from '../../lib/errors.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { validateTransition } from '../../lib/state-machine.js'
export const CancelStayInput = z.object({
stayId: z.string().uuid().describe('Stay ID to cancel'),
reason: z.string().optional().describe('Cancellation reason'),
})
export const CancelStayOutput = z.object({
stayId: z.string().describe('Cancelled stay ID'),
status: z.string().describe('New stay status'),
})
export const cancelStay = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['stays.manage'],
input: CancelStayInput,
output: CancelStayOutput,
func: async ({ kysely }, { stayId, reason }, { session }) => {
const stay = await kysely
.selectFrom('stay')
.select(['stayId', 'status'])
.where('stayId', '=', stayId)
.executeTakeFirst()
if (!stay) {
throw new NotFoundError('Stay not found')
}
validateTransition('stay', stay.status, 'cancelled')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('stay')
.set({
status: 'cancelled',
updatedByUserId: session!.userId,
updatedAt: new Date(),
})
.where('stayId', '=', stayId)
.execute()
return { stayId, status: 'cancelled' }
},
})

View File

@@ -0,0 +1,50 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { NotFoundError } from '../../lib/errors.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { validateTransition } from '../../lib/state-machine.js'
export const CheckInStayInput = z.object({
stayId: z.string().uuid().describe('Stay ID to check in'),
})
export const CheckInStayOutput = z.object({
stayId: z.string().describe('Checked-in stay ID'),
status: z.string().describe('New stay status'),
})
export const checkInStay = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['stays.manage'],
input: CheckInStayInput,
output: CheckInStayOutput,
func: async ({ kysely }, { stayId }, { session }) => {
const stay = await kysely
.selectFrom('stay')
.select(['stayId', 'status'])
.where('stayId', '=', stayId)
.executeTakeFirst()
if (!stay) {
throw new NotFoundError('Stay not found')
}
validateTransition('stay', stay.status, 'checked_in')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('stay')
.set({
status: 'checked_in',
checkedInAt: new Date(),
updatedByUserId: session!.userId,
updatedAt: new Date(),
})
.where('stayId', '=', stayId)
.execute()
return { stayId, status: 'checked_in' }
},
})

View File

@@ -0,0 +1,50 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { NotFoundError } from '../../lib/errors.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { validateTransition } from '../../lib/state-machine.js'
export const CheckOutStayInput = z.object({
stayId: z.string().uuid().describe('Stay ID to check out'),
})
export const CheckOutStayOutput = z.object({
stayId: z.string().describe('Checked-out stay ID'),
status: z.string().describe('New stay status'),
})
export const checkOutStay = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['stays.manage'],
input: CheckOutStayInput,
output: CheckOutStayOutput,
func: async ({ kysely }, { stayId }, { session }) => {
const stay = await kysely
.selectFrom('stay')
.select(['stayId', 'status'])
.where('stayId', '=', stayId)
.executeTakeFirst()
if (!stay) {
throw new NotFoundError('Stay not found')
}
validateTransition('stay', stay.status, 'checked_out')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('stay')
.set({
status: 'checked_out',
checkedOutAt: new Date(),
updatedByUserId: session!.userId,
updatedAt: new Date(),
})
.where('stayId', '=', stayId)
.execute()
return { stayId, status: 'checked_out' }
},
})

View File

@@ -0,0 +1,60 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { NotFoundError, ConflictError } from '../../lib/errors.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const CreateStayFromRequestInput = z.object({
requestId: z.string().uuid().describe('Approved stay request ID'),
startAt: z.string().optional().describe('Override start date'),
endAt: z.string().optional().describe('Override end date'),
})
export const CreateStayFromRequestOutput = z.object({
stayId: z.string().describe('Created stay ID'),
status: z.string().describe('Stay status'),
})
export const createStayFromRequest = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['stays.manage'],
input: CreateStayFromRequestInput,
output: CreateStayFromRequestOutput,
func: async ({ kysely }, { requestId, startAt, endAt }, { session }) => {
const request = await kysely
.selectFrom('stayRequest')
.select(['requestId', 'status', 'userId', 'requestedStartAt', 'requestedEndAt', 'retreatId', 'requestType'])
.where('requestId', '=', requestId)
.executeTakeFirst()
if (!request) {
throw new NotFoundError('Stay request not found')
}
if (request.status !== 'approved') {
throw new ConflictError('Stay request must be approved before creating a stay')
}
await setAuditContext(kysely, session!.userId)
const stayTypeMap: Record<string, string> = { day_visit: 'day_visitor' }
const stayType = stayTypeMap[request.requestType] || request.requestType
const result = await kysely
.insertInto('stay')
.values({
userId: request.userId,
sourceRequestId: requestId,
startAt: startAt ? new Date(startAt) : request.requestedStartAt,
endAt: endAt ? new Date(endAt) : request.requestedEndAt,
retreatId: request.retreatId,
stayType: stayType as any,
status: 'pending',
createdByUserId: session!.userId,
})
.returning(['stayId', 'status'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,72 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListStayRequestsInput = z.object({
status: z.enum(['submitted', 'under_review', 'approved', 'rejected', 'cancelled']).optional().describe('Filter by request status'),
userId: z.string().uuid().optional().describe('Filter by user ID'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListStayRequestsOutput = z.object({
items: z.array(z.object({
requestId: z.string().describe('Request ID'),
userId: z.string().describe('Requesting user ID'),
userDisplayName: z.string().nullable().describe('User display name'),
requestType: z.string().describe('Type of stay request'),
requestedStartAt: z.coerce.date().describe('Requested start date'),
requestedEndAt: z.coerce.date().describe('Requested end date'),
retreatId: z.string().nullable().describe('Associated retreat ID'),
status: z.string().describe('Request status'),
notes: z.string().nullable().describe('Additional notes'),
reviewNotes: z.string().nullable().describe('Review notes'),
reviewedByUserId: z.string().nullable().describe('Reviewer user ID'),
reviewedAt: z.coerce.date().nullable().describe('Review timestamp'),
createdAt: z.coerce.date().describe('Created timestamp'),
})).describe('List of stay requests'),
total: z.number().describe('Total number of matching requests'),
})
export const listStayRequests = pikkuFunc({
expose: true,
tags: ['stays.view_all'],
input: ListStayRequestsInput,
output: ListStayRequestsOutput,
func: async ({ kysely }, { status, userId, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('stayRequest')
.leftJoin('user', 'user.id', 'stayRequest.userId')
.select([
'stayRequest.requestId', 'stayRequest.userId', 'stayRequest.requestType',
'stayRequest.requestedStartAt', 'stayRequest.requestedEndAt',
'stayRequest.retreatId', 'stayRequest.status', 'stayRequest.notes',
'stayRequest.reviewNotes', 'stayRequest.reviewedByUserId', 'stayRequest.reviewedAt',
'stayRequest.createdAt',
'user.displayName as userDisplayName',
])
let countQuery = kysely
.selectFrom('stayRequest')
.select(kysely.fn.countAll<number>().as('count'))
if (status) {
query = query.where('status', '=', status)
countQuery = countQuery.where('status', '=', status)
}
if (userId) {
query = query.where('userId', '=', userId)
countQuery = countQuery.where('userId', '=', userId)
}
const [items, countResult] = await Promise.all([
query.orderBy('stayRequest.createdAt', 'desc').limit(limit).offset(offset).execute(),
countQuery.executeTakeFirstOrThrow(),
])
return { items, total: Number(countResult.count) }
},
})

View File

@@ -0,0 +1,80 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
export const ListStaysInput = z.object({
status: z.enum(['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled']).optional().describe('Filter by stay status'),
userId: z.string().uuid().optional().describe('Filter by user ID'),
from: z.string().optional().describe('Filter stays starting from this date'),
to: z.string().optional().describe('Filter stays ending before this date'),
limit: z.coerce.number().optional().default(50).describe('Maximum number of results'),
offset: z.coerce.number().optional().default(0).describe('Offset for pagination'),
})
export const ListStaysOutput = z.object({
items: z.array(z.object({
stayId: z.string().describe('Stay ID'),
userId: z.string().describe('Guest user ID'),
userDisplayName: z.string().nullable().describe('User display name'),
sourceRequestId: z.string().nullable().describe('Source stay request ID'),
startAt: z.coerce.date().describe('Stay start date'),
endAt: z.coerce.date().describe('Stay end date'),
retreatId: z.string().nullable().describe('Associated retreat ID'),
stayType: z.string().describe('Type of stay'),
status: z.string().describe('Stay status'),
checkedInAt: z.coerce.date().nullable().describe('Check-in timestamp'),
checkedOutAt: z.coerce.date().nullable().describe('Check-out timestamp'),
createdAt: z.coerce.date().describe('Created timestamp'),
})).describe('List of stays'),
total: z.number().describe('Total number of matching stays'),
})
export const listStays = pikkuFunc({
expose: true,
tags: ['stays.view_all'],
input: ListStaysInput,
output: ListStaysOutput,
func: async ({ kysely }, { status, userId, from, to, limit: rawLimit, offset: rawOffset }) => {
const limit = rawLimit ?? 50
const offset = rawOffset ?? 0
let query = kysely
.selectFrom('stay')
.leftJoin('user', 'user.id', 'stay.userId')
.select([
'stay.stayId', 'stay.userId', 'stay.sourceRequestId', 'stay.startAt', 'stay.endAt',
'stay.retreatId', 'stay.stayType', 'stay.status', 'stay.checkedInAt', 'stay.checkedOutAt', 'stay.createdAt',
'user.displayName as userDisplayName',
])
let countQuery = kysely
.selectFrom('stay')
.select(kysely.fn.countAll<number>().as('count'))
if (status) {
query = query.where('status', '=', status)
countQuery = countQuery.where('status', '=', status)
}
if (userId) {
query = query.where('userId', '=', userId)
countQuery = countQuery.where('userId', '=', userId)
}
if (from) {
query = query.where('startAt', '>=', new Date(from))
countQuery = countQuery.where('startAt', '>=', new Date(from))
}
if (to) {
query = query.where('endAt', '<=', new Date(to))
countQuery = countQuery.where('endAt', '<=', new Date(to))
}
const [items, countResult] = await Promise.all([
query.orderBy('startAt', 'desc').limit(limit).offset(offset).execute(),
countQuery.executeTakeFirstOrThrow(),
])
return { items, total: Number(countResult.count) }
},
})

View File

@@ -0,0 +1,52 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { NotFoundError } from '../../lib/errors.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { validateTransition } from '../../lib/state-machine.js'
export const RejectStayRequestInput = z.object({
requestId: z.string().uuid().describe('Stay request ID to reject'),
reviewNotes: z.string().describe('Reason for rejection'),
})
export const RejectStayRequestOutput = z.object({
requestId: z.string().describe('Rejected request ID'),
status: z.string().describe('New request status'),
})
export const rejectStayRequest = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['stays.review'],
input: RejectStayRequestInput,
output: RejectStayRequestOutput,
func: async ({ kysely }, { requestId, reviewNotes }, { session }) => {
const request = await kysely
.selectFrom('stayRequest')
.select(['requestId', 'status'])
.where('requestId', '=', requestId)
.executeTakeFirst()
if (!request) {
throw new NotFoundError('Stay request not found')
}
validateTransition('stay_request', request.status, 'rejected')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('stayRequest')
.set({
status: 'rejected',
reviewedByUserId: session!.userId,
reviewedAt: new Date(),
reviewNotes,
updatedAt: new Date(),
})
.where('requestId', '=', requestId)
.execute()
return { requestId, status: 'rejected' }
},
})

View File

@@ -0,0 +1,43 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
export const RequestStayInput = z.object({
requestType: z.enum(['day_visit', 'facilitator', 'guest', 'staff', 'volunteer']).describe('Type of stay request'),
requestedStartAt: z.string().describe('Requested start date'),
requestedEndAt: z.string().describe('Requested end date'),
retreatId: z.string().uuid().optional().describe('Associated retreat ID'),
notes: z.string().optional().describe('Additional notes'),
})
export const RequestStayOutput = z.object({
requestId: z.string().describe('Created stay request ID'),
status: z.string().describe('Request status'),
})
export const requestStay = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['stays.request'],
input: RequestStayInput,
output: RequestStayOutput,
func: async ({ kysely }, { requestType, requestedStartAt, requestedEndAt, retreatId, notes }, { session }) => {
await setAuditContext(kysely, session!.userId)
const result = await kysely
.insertInto('stayRequest')
.values({
userId: session!.userId,
requestType,
requestedStartAt: new Date(requestedStartAt),
requestedEndAt: new Date(requestedEndAt),
retreatId: retreatId ?? null,
notes: notes ?? null,
status: 'submitted',
})
.returning(['requestId', 'status'])
.executeTakeFirstOrThrow()
return result
},
})

View File

@@ -0,0 +1,44 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
export const AssignTaskToRoleInput = z.object({
taskId: z.string().uuid().describe('ID of the task to assign to a role'),
role: z.string().describe('Role to assign the task to'),
})
export const AssignTaskToRoleOutput = z.object({
taskId: z.string().describe('Task ID'),
assignedRole: z.string().describe('Assigned role'),
status: z.string().describe('Current task status'),
})
export const assignTaskToRole = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['tasks.assign'],
input: AssignTaskToRoleInput,
output: AssignTaskToRoleOutput,
func: async ({ kysely }, { taskId, role }, { session }) => {
const task = await kysely
.selectFrom('taskInstance')
.select(['taskId', 'status'])
.where('taskId', '=', taskId)
.executeTakeFirst()
if (!task) {
throw new NotFoundError('Task not found')
}
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('taskInstance')
.set({ assignedRole: role, updatedAt: new Date() })
.where('taskId', '=', taskId)
.execute()
return { taskId: task.taskId, assignedRole: role, status: task.status }
},
})

View File

@@ -0,0 +1,57 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
import { validateTransition } from '../../lib/state-machine.js'
export const AssignTaskInput = z.object({
taskId: z.string().uuid().describe('ID of the task to assign'),
userId: z.string().uuid().describe('ID of the user to assign the task to'),
})
export const AssignTaskOutput = z.object({
assignmentId: z.string().describe('Created assignment ID'),
taskStatus: z.string().describe('Updated task status'),
})
export const assignTask = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['tasks.assign'],
input: AssignTaskInput,
output: AssignTaskOutput,
func: async ({ kysely }, { taskId, userId }, { session }) => {
const task = await kysely
.selectFrom('taskInstance')
.select(['taskId', 'status'])
.where('taskId', '=', taskId)
.executeTakeFirst()
if (!task) {
throw new NotFoundError('Task not found')
}
validateTransition('task_instance', task.status, 'assigned')
await setAuditContext(kysely, session!.userId)
const assignment = await kysely
.insertInto('taskAssignment')
.values({
taskInstanceId: taskId,
assignedUserId: userId,
assignedByUserId: session!.userId,
status: 'proposed',
})
.returning('assignmentId')
.executeTakeFirstOrThrow()
await kysely
.updateTable('taskInstance')
.set({ status: 'assigned', updatedAt: new Date() })
.where('taskId', '=', taskId)
.execute()
return { assignmentId: assignment.assignmentId, taskStatus: 'assigned' }
},
})

View File

@@ -0,0 +1,60 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError, ConflictError } from '../../lib/errors.js'
import { validateTransition } from '../../lib/state-machine.js'
export const ClaimTaskInput = z.object({
taskId: z.string().uuid().describe('ID of the task to claim'),
})
export const ClaimTaskOutput = z.object({
assignmentId: z.string().describe('Created assignment ID'),
taskStatus: z.string().describe('Updated task status'),
})
export const claimTask = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['tasks.claim'],
input: ClaimTaskInput,
output: ClaimTaskOutput,
func: async ({ kysely }, { taskId }, { session }) => {
const task = await kysely
.selectFrom('taskInstance')
.select(['taskId', 'status'])
.where('taskId', '=', taskId)
.executeTakeFirst()
if (!task) {
throw new NotFoundError('Task not found')
}
if (task.status !== 'open') {
throw new ConflictError('Task is not open for claiming')
}
validateTransition('task_instance', task.status, 'assigned')
await setAuditContext(kysely, session!.userId)
const assignment = await kysely
.insertInto('taskAssignment')
.values({
taskInstanceId: taskId,
assignedUserId: session!.userId,
assignedByUserId: session!.userId,
status: 'accepted',
})
.returning('assignmentId')
.executeTakeFirstOrThrow()
await kysely
.updateTable('taskInstance')
.set({ status: 'assigned', updatedAt: new Date() })
.where('taskId', '=', taskId)
.execute()
return { assignmentId: assignment.assignmentId, taskStatus: 'assigned' }
},
})

View File

@@ -0,0 +1,55 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
import { setAuditContext } from '../../lib/audit-context.js'
import { NotFoundError } from '../../lib/errors.js'
import { validateTransition } from '../../lib/state-machine.js'
export const CompleteTaskInput = z.object({
taskId: z.string().uuid().describe('ID of the task to complete'),
notes: z.string().optional().describe('Completion notes'),
})
export const CompleteTaskOutput = z.object({
taskId: z.string().describe('Task ID'),
status: z.string().describe('Updated task status'),
})
export const completeTask = pikkuFunc({
expose: true,
approvalRequired: true,
tags: ['tasks.manage'],
input: CompleteTaskInput,
output: CompleteTaskOutput,
func: async ({ kysely }, { taskId, notes }, { session }) => {
const task = await kysely
.selectFrom('taskInstance')
.select(['taskId', 'status'])
.where('taskId', '=', taskId)
.executeTakeFirst()
if (!task) {
throw new NotFoundError('Task not found')
}
validateTransition('task_instance', task.status, 'completed')
await setAuditContext(kysely, session!.userId)
await kysely
.updateTable('taskInstance')
.set({ status: 'completed', updatedAt: new Date() })
.where('taskId', '=', taskId)
.execute()
if (notes) {
await kysely
.updateTable('taskAssignment')
.set({ notes, updatedAt: new Date() })
.where('taskInstanceId', '=', taskId)
.where('status', '=', 'accepted')
.execute()
}
return { taskId, status: 'completed' }
},
})

Some files were not shown because too many files have changed in this diff Show More