Compare commits

...

5 Commits

Author SHA1 Message Date
Test User daf8a7bd56 fix(GRO-693): add UAT Terraform infrastructure with correct GitRepository
- Add terraform workspace at apps/overlays/uat/terraform/ (backend.tf,
  main.tf, variables.tf, users.tf, imports.tf, terraform.tfvars)
- Add Terraform CRD (authentik-terraform.yaml) with correct path
  ./apps/overlays/uat/terraform relative to groombook/app repo root
- Add GitRepository CRD (gitrepository-groombook.yaml) pointing to
  groombook/app at fix/gro-844-network-policy branch (NOT groombook/infra
  which no longer exists)
- Add kustomization.yaml to tie it together

Root cause: the GitRepository was pointing to https://github.com/groombook/infra
which no longer exists, and the terraform files were not committed to the
current repository at the correct path.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 19:55:35 +00:00
the-dogfather-cto[bot] 3c366ccc46 Merge pull request #346 from groombook/fix/gro-816-portal-pets-crash
fix(GRO-816): fix PetProfiles crash from appointments response shape change
2026-04-19 11:02:07 +00:00
Test User ff149f75dc fix(GRO-816): remove unused 'now' variable from portal.ts appointments handler
The PR refactored appointments response from { upcoming, past } to
{ appointments: [] } but the `now` variable used to compute those
filters was left behind. ESLint correctly flags it as unused.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 10:52:13 +00:00
Flea Flicker 03bd2d0235 fix(GRO-816): update PetProfiles.tsx to use new appointments response shape
- PetProfiles.tsx: update AppointmentsResponse interface to use flat
  appointments[] array instead of { upcoming, past }
- PetProfiles.tsx: update petHistory filter to use appointments.appointments
  with date filter for past-only appointments
- portal.ts: change /api/portal/appointments response to { appointments: [] }
  instead of { upcoming: [], past: [] }
- portal.ts: change /api/portal/pets response field names to match frontend
  Pet interface: weightKg→weight, dateOfBirth→birthDate, photoKey→photoUrl,
  groomingNotes→notes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 08:13:53 +00:00
the-dogfather-cto[bot] 4f85a4a432 feat(gro-609): add refund handling and payment stats to admin (#341)
feat(gro-609): add refund handling and payment stats to admin
2026-04-19 02:05:06 +00:00
12 changed files with 380 additions and 11 deletions
+2 -6
View File
@@ -102,7 +102,6 @@ portalRouter.get("/appointments", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const now = new Date();
const allAppts = await db
.select({
id: appointments.id,
@@ -142,10 +141,7 @@ portalRouter.get("/appointments", async (c) => {
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
}));
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
return c.json({ upcoming, past });
return c.json({ appointments: appts });
});
portalRouter.get("/pets", async (c) => {
@@ -153,7 +149,7 @@ portalRouter.get("/pets", async (c) => {
const clientId = c.get("portalClientId");
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
});
portalRouter.get("/invoices", async (c) => {
+1 -1
View File
@@ -9,8 +9,8 @@ const RATE_LIMIT_MAX = 10;
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
const now = Date.now();
const entry = rateLimitMap.get(ip);
const now = Date.now();
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
@@ -0,0 +1,53 @@
# =============================================================================
# Terraform CRD for Flux ToFu Controller — Authentik groombook-uat
# =============================================================================
# This CRD tells the Flux ToFu Controller to reconcile the Terraform
# workspace at apps/overlays/uat/terraform/
#
# The ToFu Controller will:
# 1. Clone the groombook/app GitRepository
# 2. Run tofu init + tofu plan/apply in the specified path
# 3. Store Terraform state in a Kubernetes secret (backend.tf)
# 4. Inject TF_VAR_authentik_token from the authentik-credentials secret
# via tf-controller varsFrom (maps secret key to Terraform variable)
#
# ApiVersion: infra.contrib.fluxcd.io/v1alpha2 (tf-controller)
# =============================================================================
apiVersion: infra.contrib.fluxcd.io/v1alpha2
kind: Terraform
metadata:
name: authentik-uat
namespace: groombook-uat
labels:
app.kubernetes.io/name: authentik
app.kubernetes.io/part-of: groombook
app.kubernetes.io/env: uat
spec:
# Reconcile every hour
interval: 1h
# Path within the GitRepository (groombook/app)
path: ./apps/overlays/uat/terraform
# Source reference — must match the GitRepository name watching this repo
sourceRef:
kind: GitRepository
name: groombook
# Auto-approve plans (no manual intervention needed for infrastructure)
approvePlan: "auto"
# Clean up Terraform resources when this CRD is deleted
destroyResourcesOnDeletion: true
# Inject TF_VAR_authentik_token from the sealed secret via tf-controller varsFrom
# (maps secret key "authentik_token" to Terraform var.authentik_token)
varsFrom:
- kind: Secret
name: authentik-credentials
- kind: Secret
name: authentik-uat-users-credentials
runnerPodTemplate:
spec: {}
@@ -0,0 +1,19 @@
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: groombook
namespace: groombook-uat
labels:
app.kubernetes.io/name: groombook
app.kubernetes.io/part-of: groombook
app.kubernetes.io/env: uat
spec:
interval: 15m
provider: github
ref:
branch: fix/gro-844-network-policy
secretRef:
name: cpfarhood-k8s
timeout: 60s
url: https://github.com/groombook/app
+6
View File
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: groombook-uat
resources:
- gitrepository-groombook.yaml
- authentik-terraform.yaml
+21
View File
@@ -0,0 +1,21 @@
# =============================================================================
# Backend configuration for Terraform state
# =============================================================================
# Uses Kubernetes backend with tf-controller managed state secret.
# tf-controller creates a Kubernetes Secret named:
# tfstate-<name>-<secret_suffix>
# i.e. tfstate-authentik-uat-authentik-uat-tf-state
# in the namespace specified by the Terraform CRD metadata.namespace (groombook-uat).
#
# Valid Kubernetes backend attributes for tf-controller:
# secret_suffix, namespace, config_path, cluster_ca_cert, client_certificate,
# client_key, token, exec, host, insecure, username, password,
# in_cluster, load_config, config_paths
# =============================================================================
terraform {
backend "kubernetes" {
secret_suffix = "authentik-uat-tf-state"
namespace = "groombook-uat"
}
}
+12
View File
@@ -0,0 +1,12 @@
# Import existing Authentik resources into Terraform state.
# These blocks are consumed on the first apply and become no-ops thereafter.
import {
to = authentik_oauth2_provider.groombook-uat
id = "284"
}
import {
to = authentik_application.groombook-uat
id = "e77a9c45-bed6-4a23-bc62-178f166f099e"
}
+99
View File
@@ -0,0 +1,99 @@
# =============================================================================
# Terraform configuration for Authentik groombook-uat application
# =============================================================================
# This Terraform workspace manages the Authentik OAuth2 application and provider
# for the groombook-uat environment.
#
# The authentik_token used for authentication is sourced from the
# `authentik-credentials` SealedSecret (injected as TF_VAR_authentik_token
# by the Terraform CRD runnerPodTemplate.spec.varsFrom).
#
# To import existing resources (run via tf-controller exec or locally with
# AUTHENTIK_TOKEN set):
# tofu import authentik_oauth2_provider.groombook-uat pk-284
# tofu import authentik_application.groombook-uat e77a9c45-bed6-4a23-bc62-178f166f099e
# =============================================================================
# -----------------------------------------------------------------------------
# Provider configuration
# -----------------------------------------------------------------------------
terraform {
required_providers {
authentik = {
source = "goauthentik/authentik"
version = "~> 2024.12"
}
}
}
provider "authentik" {
url = var.authentik_url
api_token = var.authentik_token
tls_verify = true
}
# -----------------------------------------------------------------------------
# OAuth2 Provider for groombook-uat
# pk = 284 (existing — imported, not recreated)
# -----------------------------------------------------------------------------
resource "authentik_oauth2_provider" "groombook-uat" {
name = "groombook-uat-provider"
slug = "groombook-uat"
client_id = "" # managed by imported resource; tracked via ignore_changes
client_secret = "" # managed by imported resource; tracked via ignore_changes
client_type = "confidential"
redirect_uris = ["https://uat.groombook.dev/api/auth/oauth2/callback/authentik"]
signing_key = "authentik signing key"
# Keep Terraform from overwriting the client_id, client_secret, and signing_key
# which are managed by the imported existing resource
lifecycle {
ignore_changes = [
client_id,
client_secret,
signing_key,
]
}
}
# -----------------------------------------------------------------------------
# Application for groombook-uat
# pk = e77a9c45-bed6-4a23-bc62-178f166f099e (existing — imported, not recreated)
# -----------------------------------------------------------------------------
resource "authentik_application" "groombook-uat" {
name = "groombook-uat"
slug = "groombook-uat"
group = "groombook"
policy_ids = []
description = "GroomBook UAT application"
# Link to the OAuth2 provider
oauth2_provider = authentik_oauth2_provider.groombook-uat.id
# Track name, slug, group, and oauth2_provider for drift detection;
# ignore policy_ids and description which may be updated out-of-band
lifecycle {
ignore_changes = [
policy_ids,
description,
]
}
}
# -----------------------------------------------------------------------------
# Outputs (for reference / verification)
# -----------------------------------------------------------------------------
output "oauth2_provider_pk" {
description = "Authentik OAuth2 Provider primary key"
value = authentik_oauth2_provider.groombook-uat.pk
}
output "application_pk" {
description = "Authentik Application primary key"
value = authentik_application.groombook-uat.pk
}
output "application_slug" {
description = "Authentik Application slug"
value = authentik_application.groombook-uat.slug
}
@@ -0,0 +1,10 @@
# =============================================================================
# Terraform variable values for groombook-uat
# =============================================================================
# NOTE: authentik_token should be provided via AUTHENTIK_TOKEN env var,
# sourced from the authentik-credentials SealedSecret.
# The placeholder value here is not used when running via tf-controller.
# =============================================================================
authentik_url = "https://auth.farh.net"
# authentik_token = "<set via AUTHENTIK_TOKEN env var from authentik-credentials secret>"
+121
View File
@@ -0,0 +1,121 @@
# =============================================================================
# Authentik UAT user personas — Terraform resources
# =============================================================================
# Creates three Authentik users bound to the groombook-uat application:
# - UAT Super User (manager role, superuser)
# - UAT Groomer (staff/groomer role)
# - UAT Customer (no staff record — auth identity only)
#
# Passwords are sourced from sensitive Terraform variables which are injected
# via tf-controller varsFrom from the authentik-uat-users-credentials SealedSecret.
#
# User PKs are exported as outputs — these are the OIDC sub claims in Authentik.
# =============================================================================
# -----------------------------------------------------------------------------
# Group: groombook-uat-users
# -----------------------------------------------------------------------------
resource "authentik_group" "groombook-uat-users" {
name = "groombook-uat-users"
}
# -----------------------------------------------------------------------------
# User: UAT Super User
# -----------------------------------------------------------------------------
resource "authentik_user" "uat-super" {
name = "UAT Super User"
username = "uat-super"
email = "uat-super@groombook.dev"
password = var.uat_super_password
active = true
# Attributes stored as JSON string per authentik_user schema
attributes_json = jsonencode({
role = "manager"
})
}
# Add uat-super to the group
resource "authentik_group_membership" "uat-super" {
group = authentik_group.groombook-uat-users.id
user = authentik_user.uat-super.pk
}
# Bind the group to the groombook-uat application via policy binding
# This grants group members authentication access to the application
resource "authentik_policy_binding" "uat-super-group-binding" {
policy = authentik_group.groombook-uat-users.id
target = authentik_application.groombook-uat.pk
binding_type = "group_whitelist"
}
# -----------------------------------------------------------------------------
# User: UAT Groomer (Staff)
# -----------------------------------------------------------------------------
resource "authentik_user" "uat-groomer" {
name = "UAT Groomer"
username = "uat-groomer"
email = "uat-groomer@groombook.dev"
password = var.uat_groomer_password
active = true
attributes_json = jsonencode({
role = "groomer"
})
}
# Add uat-groomer to the group
resource "authentik_group_membership" "uat-groomer" {
group = authentik_group.groombook-uat-users.id
user = authentik_user.uat-groomer.pk
}
# Bind the group to the groombook-uat application
resource "authentik_policy_binding" "uat-groomer-group-binding" {
policy = authentik_group.groombook-uat-users.id
target = authentik_application.groombook-uat.pk
binding_type = "group_whitelist"
}
# -----------------------------------------------------------------------------
# User: UAT Customer
# -----------------------------------------------------------------------------
resource "authentik_user" "uat-customer" {
name = "UAT Customer"
username = "uat-customer"
email = "uat-customer@groombook.dev"
password = var.uat_customer_password
active = true
attributes_json = jsonencode({
role = "customer"
})
}
# Add uat-customer to the group
resource "authentik_group_membership" "uat-customer" {
group = authentik_group.groombook-uat-users.id
user = authentik_user.uat-customer.pk
}
# Bind the group to the groombook-uat application
resource "authentik_policy_binding" "uat-customer-group-binding" {
policy = authentik_group.groombook-uat-users.id
target = authentik_application.groombook-uat.pk
binding_type = "group_whitelist"
}
# -----------------------------------------------------------------------------
# Outputs — OIDC sub claims (= user PK in Authentik)
# -----------------------------------------------------------------------------
output "uat_super_user_pk" {
description = "UAT Super User primary key (OIDC sub)"
value = authentik_user.uat-super.pk
}
output "uat_groomer_user_pk" {
description = "UAT Groomer primary key (OIDC sub)"
value = authentik_user.uat-groomer.pk
}
output "uat_customer_user_pk" {
description = "UAT Customer primary key (OIDC sub)"
value = authentik_user.uat-customer.pk
}
+33
View File
@@ -0,0 +1,33 @@
# =============================================================================
# Variables for Authentik groombook-uat Terraform workspace
# =============================================================================
variable "authentik_url" {
description = "Base URL of the Authentik instance"
type = string
default = "https://auth.farh.net"
}
variable "authentik_token" {
description = "API token for Authentik (from authentik-credentials secret via AUTHENTIK_TOKEN env var)"
type = string
sensitive = true
}
variable "uat_super_password" {
description = "Password for the UAT Super User account"
type = string
sensitive = true
}
variable "uat_groomer_password" {
description = "Password for the UAT Groomer staff account"
type = string
sensitive = true
}
variable "uat_customer_password" {
description = "Password for the UAT Customer account"
type = string
sensitive = true
}
+3 -4
View File
@@ -27,8 +27,7 @@ interface Appointment {
}
interface AppointmentsResponse {
upcoming: Appointment[];
past: Appointment[];
appointments: Appointment[];
}
interface Props {
@@ -46,7 +45,7 @@ function buildHeaders(sessionId: string | null): Record<string, string> {
export function PetProfiles({ sessionId, readOnly }: Props) {
const [pets, setPets] = useState<Pet[]>([]);
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
const [selectedPetId, setSelectedPetId] = useState<string>("");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
const [editingPetId, setEditingPetId] = useState<string | null>(null);
@@ -90,7 +89,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
}, [sessionId]);
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
function handlePetSave(updatedPet: Pet) {