chore: perauset customer project
This commit is contained in:
20
packages/components/package.json
Normal file
20
packages/components/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
131
packages/components/src/data-table.tsx
Normal file
131
packages/components/src/data-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
packages/components/src/index.ts
Normal file
3
packages/components/src/index.ts
Normal 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'
|
||||
32
packages/components/src/page-header.tsx
Normal file
32
packages/components/src/page-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
packages/components/src/status-badge.tsx
Normal file
37
packages/components/src/status-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
packages/functions-sdk/package.json
Normal file
20
packages/functions-sdk/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
37
packages/functions/package.json
Normal file
37
packages/functions/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
149
packages/functions/src/agents/admin.agent.ts
Normal file
149
packages/functions/src/agents/admin.agent.ts
Normal 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',
|
||||
})
|
||||
35
packages/functions/src/agents/boats.agent.ts
Normal file
35
packages/functions/src/agents/boats.agent.ts
Normal 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',
|
||||
})
|
||||
27
packages/functions/src/agents/finance.agent.ts
Normal file
27
packages/functions/src/agents/finance.agent.ts
Normal 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',
|
||||
})
|
||||
26
packages/functions/src/agents/inventory.agent.ts
Normal file
26
packages/functions/src/agents/inventory.agent.ts
Normal 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',
|
||||
})
|
||||
22
packages/functions/src/agents/kitchen.agent.ts
Normal file
22
packages/functions/src/agents/kitchen.agent.ts
Normal 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',
|
||||
})
|
||||
29
packages/functions/src/agents/retreats.agent.ts
Normal file
29
packages/functions/src/agents/retreats.agent.ts
Normal 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',
|
||||
})
|
||||
24
packages/functions/src/agents/rooms.agent.ts
Normal file
24
packages/functions/src/agents/rooms.agent.ts
Normal 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',
|
||||
})
|
||||
35
packages/functions/src/agents/router.agent.ts
Normal file
35
packages/functions/src/agents/router.agent.ts
Normal 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,
|
||||
})
|
||||
26
packages/functions/src/agents/stays.agent.ts
Normal file
26
packages/functions/src/agents/stays.agent.ts
Normal 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',
|
||||
})
|
||||
29
packages/functions/src/agents/tasks.agent.ts
Normal file
29
packages/functions/src/agents/tasks.agent.ts
Normal 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',
|
||||
})
|
||||
26
packages/functions/src/application-types.d.ts
vendored
Normal file
26
packages/functions/src/application-types.d.ts
vendored
Normal 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> {}
|
||||
20
packages/functions/src/config.ts
Normal file
20
packages/functions/src/config.ts
Normal 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',
|
||||
},
|
||||
}));
|
||||
@@ -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(),
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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) }
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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(),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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),
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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(),
|
||||
}
|
||||
},
|
||||
})
|
||||
17
packages/functions/src/functions/health-check.function.ts
Normal file
17
packages/functions/src/functions/health-check.function.ts
Normal 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 }
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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),
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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),
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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) }
|
||||
},
|
||||
})
|
||||
@@ -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) }
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
},
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user