diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index df10baa..b08c640 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -78,6 +78,8 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + with: + driver-opts: network=host - name: Log in to Gitea Container Registry uses: docker/login-action@v3 @@ -89,10 +91,12 @@ jobs: - name: Build and push API image uses: docker/build-push-action@v6 with: + provenance: false context: . file: Dockerfile target: runner push: true + provenance: false tags: | git.farh.net/groombook/api:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }} @@ -102,10 +106,12 @@ jobs: - name: Build and push Migrate image uses: docker/build-push-action@v6 with: + provenance: false context: . file: Dockerfile target: migrate push: true + provenance: false tags: | git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }} @@ -115,10 +121,12 @@ jobs: - name: Build and push Seed image uses: docker/build-push-action@v6 with: + provenance: false context: . file: Dockerfile target: seed push: true + provenance: false tags: | git.farh.net/groombook/seed:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }} @@ -128,10 +136,12 @@ jobs: - name: Build and push Reset image uses: docker/build-push-action@v6 with: + provenance: false context: . file: Dockerfile target: reset push: true + provenance: false tags: | git.farh.net/groombook/reset:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }} diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index cb02d20..d5887c6 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -21,6 +21,14 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet ## Test Cases +### 4.0 Health Check + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` | + +> **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule). + ### 4.1 Authentication | # | Scenario | Steps | Expected | diff --git a/apps/api/src/routes/admin/seed.ts b/apps/api/src/routes/admin/seed.ts index 1220991..0f3dbe2 100644 --- a/apps/api/src/routes/admin/seed.ts +++ b/apps/api/src/routes/admin/seed.ts @@ -36,6 +36,19 @@ const DEMO_PET = { weightKg: "30.00", }; +const UAT_CLIENT = { + name: "UAT Customer", + email: "uat-customer@groombook.dev", + phone: "555-0100", + address: "1 UAT Lane, Test City, CA 90210", + status: "active" as const, +}; + +const UAT_PETS = [ + { name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly" as const, weightKg: "20.00" }, + { name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "short" as const, weightKg: "30.00" }, +]; + const DEMO_SERVICES = [ { id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, { id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, @@ -43,7 +56,7 @@ const DEMO_SERVICES = [ { id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, ]; -adminSeedRouter.post("/seed", async (c) => { +adminSeedRouter.post("/", async (c) => { // Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding if (process.env.AUTH_DISABLED === "true") { return c.json( @@ -128,6 +141,51 @@ adminSeedRouter.post("/seed", async (c) => { results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`); } + // ── Client: UAT Customer ────────────────────────────────────────────────── + const [existingUatClient] = await db + .select() + .from(clients) + .where(eq(clients.email, UAT_CLIENT.email)); + + let uatClientId: string; + if (existingUatClient) { + uatClientId = existingUatClient.id; + results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`); + } else { + const [created] = await db.insert(clients).values(UAT_CLIENT).returning(); + uatClientId = created!.id; + results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`); + } + + // ── Pets: UAT Customer's Pets ───────────────────────────────────────────── + const existingUatPets = await db + .select() + .from(pets) + .where(eq(pets.clientId, uatClientId)); + + for (const uatPet of UAT_PETS) { + const existingPet = existingUatPets.find( + (p) => p.name === uatPet.name && p.species === uatPet.species + ); + if (existingPet) { + results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existingPet.id})`); + } else { + const [created] = await db + .insert(pets) + .values({ + clientId: uatClientId, + name: uatPet.name, + species: uatPet.species, + breed: uatPet.breed, + coatType: uatPet.coatType, + weightKg: uatPet.weightKg, + dateOfBirth: new Date("2019-01-01T00:00:00Z"), + }) + .returning(); + results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`); + } + } + return c.json({ message: "Seed complete", details: results, @@ -136,4 +194,4 @@ adminSeedRouter.post("/seed", async (c) => { staffOidcSub: KNOWN_STAFF.oidcSub, }, }); -}); +}); \ No newline at end of file diff --git a/packages/db/migrations/0031_buffer_rules.sql b/packages/db/migrations/0031_buffer_rules.sql index 5bfd90a..b4ee53b 100644 --- a/packages/db/migrations/0031_buffer_rules.sql +++ b/packages/db/migrations/0031_buffer_rules.sql @@ -6,8 +6,10 @@ CREATE TYPE "pet_size_category" AS ENUM ('small', 'medium', 'large', 'xlarge'); CREATE TYPE "coat_type" AS ENUM ('smooth', 'double', 'wire', 'curly', 'long', 'hairless'); --- ─── Alter pets columns to use new enums ───────────────────────────────────── +-- ─── Add columns to pets if missing, then cast to enums ────────────────────── +ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "coat_type" text; +ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "pet_size_category" text; ALTER TABLE "pets" ALTER COLUMN "coat_type" TYPE "coat_type" USING "coat_type"::text::"coat_type"; ALTER TABLE "pets" ALTER COLUMN "pet_size_category" TYPE "pet_size_category" USING "pet_size_category"::text::"pet_size_category"; diff --git a/packages/db/migrations/0032_staff_read_at.sql b/packages/db/migrations/0032_staff_read_at.sql new file mode 100644 index 0000000..da55b19 --- /dev/null +++ b/packages/db/migrations/0032_staff_read_at.sql @@ -0,0 +1 @@ +-- no-op: journal entry exists but no schema change was needed diff --git a/packages/db/migrations/0033_add_services_default_buffer_minutes.sql b/packages/db/migrations/0033_add_services_default_buffer_minutes.sql new file mode 100644 index 0000000..be22c59 --- /dev/null +++ b/packages/db/migrations/0033_add_services_default_buffer_minutes.sql @@ -0,0 +1,6 @@ +-- Migration: 0033_add_services_default_buffer_minutes.sql +-- Adds missing default_buffer_minutes column to services table. +-- 0031_buffer_rules was applied to the DB but its journal entry was missing, +-- so this ensures idempotent column addition for fresh DB restores. + +ALTER TABLE "services" ADD COLUMN IF NOT EXISTS "default_buffer_minutes" integer DEFAULT 0 NOT NULL; diff --git a/packages/db/migrations/meta/0033_snapshot.json b/packages/db/migrations/meta/0033_snapshot.json new file mode 100644 index 0000000..74e4f16 --- /dev/null +++ b/packages/db/migrations/meta/0033_snapshot.json @@ -0,0 +1,103 @@ +{ + "id": "0033_add_services_default_buffer_minutes", + "version": "7", + "dialect": "postgresql", + "tables": { + "authProviderConfig": { + "name": "auth_provider_config", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "providerId": { "name": "provider_id", "type": "text", "isNullable": false }, + "displayName": { "name": "display_name", "type": "text", "isNullable": false }, + "issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false }, + "internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true }, + "clientId": { "name": "client_id", "type": "text", "isNullable": false }, + "clientSecret": { "name": "client_secret", "type": "text", "isNullable": false }, + "scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" }, + "enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" }, + "createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {} + }, + "businessSettings": { + "name": "business_settings", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" }, + "logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true }, + "logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true }, + "logoKey": { "name": "logo_key", "type": "text", "isNullable": true }, + "primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" }, + "accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" }, + "createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {} + }, + "clients": { + "name": "clients", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "name": { "name": "name", "type": "text", "isNullable": false }, + "email": { "name": "email", "type": "text", "isNullable": true }, + "phone": { "name": "phone", "type": "text", "isNullable": true }, + "address": { "name": "address", "type": "text", "isNullable": true }, + "notes": { "name": "notes", "type": "text", "isNullable": true }, + "emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" }, + "smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" }, + "smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true }, + "smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true }, + "smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true }, + "stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true }, + "status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" }, + "disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true }, + "createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } } + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true }, + "clientId": { "name": "client_id", "type": "uuid", "isNullable": false }, + "subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false }, + "taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" }, + "tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" }, + "totalCents": { "name": "total_cents", "type": "integer", "isNullable": false }, + "status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" }, + "paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true }, + "paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true }, + "stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true }, + "stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true }, + "paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true }, + "notes": { "name": "notes", "type": "text", "isNullable": true }, + "createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } }, + "foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } } + } + }, + "enums": { + "appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] }, + "client_status": { "name": "client_status", "values": ["active", "disabled"] }, + "impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] }, + "invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] }, + "payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] }, + "staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] }, + "waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] } + }, + "nativeEnums": {} +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index eef2244..a364fe1 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -218,6 +218,27 @@ "when": 1775828067192, "tag": "0030_messaging", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1775860800000, + "tag": "0031_buffer_rules", + "breakpoints": true + }, + { + "idx": 32, + "version": "7", + "when": 1775894400000, + "tag": "0032_staff_read_at", + "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1779500000000, + "tag": "0033_add_services_default_buffer_minutes", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index 2ecad06..c15d42e 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -105,6 +105,10 @@ export function buildPet(overrides: Partial & { clientId: string }): Pet photoKey: null, photoUploadedAt: null, image: null, + temperamentScore: null, + temperamentFlags: [], + medicalAlerts: [], + preferredCuts: [], createdAt: new Date("2025-01-01T00:00:00Z"), updatedAt: new Date("2025-01-01T00:00:00Z"), }; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 8b3b01f..e3bc914 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -12,7 +12,7 @@ export function getDb() { if (_db) return _db; const url = process.env.DATABASE_URL; if (!url) throw new Error("DATABASE_URL is not set"); - const client = postgres(url, { max: 10 }); + const client = postgres(url, { max: 10, connect_timeout: 5 }); _db = drizzle(client, { schema }); return _db; } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index de0ab29..3a12d96 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -11,6 +11,7 @@ import { unique, uuid, } from "drizzle-orm/pg-core"; +import type { MedicalAlert } from "@groombook/types"; // ─── Enums ──────────────────────────────────────────────────────────────────── @@ -164,6 +165,10 @@ export const pets = pgTable( specialCareNotes: text("special_care_notes"), coatType: coatTypeEnum("coat_type"), petSizeCategory: petSizeCategoryEnum("pet_size_category"), + temperamentScore: integer("temperament_score"), + temperamentFlags: jsonb("temperament_flags").$type().default([]), + medicalAlerts: jsonb("medical_alerts").$type().default([]), + preferredCuts: jsonb("preferred_cuts").$type().default([]), customFields: jsonb("custom_fields").$type>().notNull().default({}), photoKey: text("photo_key"), photoUploadedAt: timestamp("photo_uploaded_at"), diff --git a/src/index.ts b/src/index.ts index 6acee60..3dd8921 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,8 +58,11 @@ app.use( }) ); -// Health check (no auth required) +// Health check — no auth required, registered on app at full path before auth middleware +// /health: used by Dockerfile HEALTHCHECK and K8s readinessProbe/livenessProbe (port 3000 direct) app.get("/health", (c) => c.json({ status: "ok" })); +// /api/health: used by Gateway HTTPRoute (/api/* → API pod) +app.get("/api/health", (c) => c.json({ status: "ok" })); // Public booking routes — no auth required, must be registered before auth middleware app.route("/api/book", bookRouter); @@ -282,14 +285,16 @@ startReminderScheduler(); function shutdown() { console.log("Shutting down gracefully..."); + // SIGTERM/SIGINT → server.close() → callback → process.exit(0) + // If graceful close takes >8s, force-exit to avoid being killed undrained + setTimeout(() => { + console.error("Graceful close timeout — forcing exit"); + process.exit(1); + }, 8_000); server.close(() => { console.log("HTTP server closed"); process.exit(0); }); - setTimeout(() => { - console.error("Forced shutdown after timeout"); - process.exit(1); - }, 10_000); } process.on("SIGTERM", shutdown); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 9e78740..da2b2d1 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -186,7 +186,9 @@ export async function initAuth(): Promise { const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`; let oidcConfig: Record = {}; try { - const discoveryRes = await fetch(discoveryUrlStr); + const discoveryRes = await fetch(discoveryUrlStr, { + signal: AbortSignal.timeout(5000), + }); if (discoveryRes.ok) { const discovery = await discoveryRes.json() as { authorization_endpoint?: string; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 906f505..830350f 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -23,7 +23,7 @@ if (process.env.AUTH_DISABLED === "true") { } export const authMiddleware: MiddlewareHandler = async (c, next) => { - if (c.req.path.startsWith("/api/auth/")) { + if (c.req.path.startsWith("/api/auth/") || c.req.path === "/api/health") { await next(); return; } diff --git a/src/routes/admin/seed.ts b/src/routes/admin/seed.ts index efd461e..114461f 100644 --- a/src/routes/admin/seed.ts +++ b/src/routes/admin/seed.ts @@ -36,6 +36,19 @@ const DEMO_PET = { weightKg: "30.00", }; +const UAT_CLIENT = { + name: "UAT Customer", + email: "uat-customer@groombook.dev", + phone: "555-0100", + address: "1 UAT Lane, Test City, CA 90210", + status: "active" as const, +}; + +const UAT_PETS = [ + { name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly", weightKg: "20.00" }, + { name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "short", weightKg: "30.00" }, +]; + const DEMO_SERVICES = [ { id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, { id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, @@ -43,7 +56,7 @@ const DEMO_SERVICES = [ { id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, ]; -adminSeedRouter.post("/seed", async (c) => { +adminSeedRouter.post("/", async (c) => { // Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding if (process.env.AUTH_DISABLED === "true") { return c.json( @@ -128,6 +141,51 @@ adminSeedRouter.post("/seed", async (c) => { results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`); } + // ── Client: UAT Customer ────────────────────────────────────────────────── + const [existingUatClient] = await db + .select() + .from(clients) + .where(eq(clients.email, UAT_CLIENT.email)); + + let uatClientId: string; + if (existingUatClient) { + uatClientId = existingUatClient.id; + results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`); + } else { + const [created] = await db.insert(clients).values(UAT_CLIENT).returning(); + uatClientId = created!.id; + results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`); + } + + // ── Pets: UAT Customer's Pets ───────────────────────────────────────────── + const existingUatPets = await db + .select() + .from(pets) + .where(eq(pets.clientId, uatClientId)); + + for (const uatPet of UAT_PETS) { + const existing = existingUatPets.find( + (p) => p.name === uatPet.name && p.species === uatPet.species + ); + if (existing) { + results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existing.id})`); + } else { + const [created] = await db + .insert(pets) + .values({ + clientId: uatClientId, + name: uatPet.name, + species: uatPet.species, + breed: uatPet.breed, + coatType: uatPet.coatType as any, + weightKg: uatPet.weightKg, + dateOfBirth: new Date("2019-01-01T00:00:00Z"), + }) + .returning(); + results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`); + } + } + return c.json({ message: "Seed complete", details: results, diff --git a/trigger-uat-1779751324.txt b/trigger-uat-1779751324.txt new file mode 100644 index 0000000..e69de29