diff --git a/.gitignore b/.gitignore index 9abbace..d34aa75 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,11 @@ node_modules/ npm-debug.log* yarn-error.log* -.claude/ -.abacusai/ - -graphify-out/* \ No newline at end of file +.claude/* +.abacusai/* +graphify-out/* +.graphify_ast_extract.py +.graphify_ast.json +.graphify_detect.json +.graphify_python +.graphify_uncached.txt diff --git a/app/Controllers/CustomerController.php b/app/Controllers/CustomerController.php index 37e82a4..b52d321 100644 --- a/app/Controllers/CustomerController.php +++ b/app/Controllers/CustomerController.php @@ -91,6 +91,21 @@ class CustomerController extends Controller ]); } + $encodedValues = json_encode($form['attribute_values'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + $duplicate = $this->repo()->findDuplicate((int) $form['customer_type_id'], $encodedValues); + + if ($duplicate !== null) { + $model->form = $form; + $model->errors['_duplicate'] = [ + 'A customer with these exact values already exists: Customer #' . (int) $duplicate['id'] . ' (' . htmlspecialchars((string) $duplicate['customer_type_name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ').', + ]; + + return $this->view('customers.create', [ + 'model' => $model, + 'pageTitle' => $model->title, + ]); + } + $customer = new Customer(); $customer->customerTypeId = (int) $form['customer_type_id']; $customer->attributeValues = $form['attribute_values']; @@ -159,6 +174,21 @@ class CustomerController extends Controller ]); } + $encodedValues = json_encode($form['attribute_values'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + $duplicate = $this->repo()->findDuplicate((int) $form['customer_type_id'], $encodedValues, (int) $id); + + if ($duplicate !== null) { + $model->form = $form; + $model->errors['_duplicate'] = [ + 'These values are identical to an existing customer: Customer #' . (int) $duplicate['id'] . ' (' . htmlspecialchars((string) $duplicate['customer_type_name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ').', + ]; + + return $this->view('customers.edit', [ + 'model' => $model, + 'pageTitle' => $model->title, + ]); + } + $customer = new Customer(); $customer->id = (int) $id; $customer->customerTypeId = (int) $form['customer_type_id']; diff --git a/app/Repositories/CustomerRepository.php b/app/Repositories/CustomerRepository.php index bf263c4..6a85055 100644 --- a/app/Repositories/CustomerRepository.php +++ b/app/Repositories/CustomerRepository.php @@ -88,6 +88,24 @@ class CustomerRepository extends Repository ); } + /** Returns an existing customer with the same type and attribute values, excluding $excludeId (use for update). */ + public function findDuplicate(int $typeId, string $attributeValuesJson, int $excludeId = 0): ?array + { + $sql = 'SELECT TOP (1) c.id, ct.name AS customer_type_name + FROM customer c + INNER JOIN customer_type ct ON c.customer_type_id = ct.id + WHERE c.customer_type_id = :type_id + AND c.attribute_values = :attribute_values'; + $params = ['type_id' => $typeId, 'attribute_values' => $attributeValuesJson]; + + if ($excludeId > 0) { + $sql .= ' AND c.id != :exclude_id'; + $params['exclude_id'] = $excludeId; + } + + return $this->database->first($sql, $params); + } + /** Used after INSERT to recover the generated id for audit logging. */ public function findLatestByType(int $typeId): ?array { diff --git a/app/Views/customers/create.php b/app/Views/customers/create.php index c8d38c0..f2ad9d8 100644 --- a/app/Views/customers/create.php +++ b/app/Views/customers/create.php @@ -26,6 +26,10 @@ window.__initialCtVals = form['attribute_values'], JSON_
errors['_token'][0]) ?>
+ errors['_duplicate'])): ?> +
errors['_duplicate'][0] ?>
+ +
diff --git a/app/Views/customers/edit.php b/app/Views/customers/edit.php index 0e73b26..a50c9d3 100644 --- a/app/Views/customers/edit.php +++ b/app/Views/customers/edit.php @@ -27,6 +27,10 @@ window.__initialCtVals = form['attribute_values'], JSON_
errors['_token'][0]) ?>
+ errors['_duplicate'])): ?> +
errors['_duplicate'][0] ?>
+ + diff --git a/docs/README.md b/docs/README.md index b3fd0ee..d98e8a6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -62,3 +62,7 @@ Browser → public/index.php → Request → Dispatcher → Router → Route → ## Flow chart See [`REQUEST_FLOW.md`](./REQUEST_FLOW.md) for a chart of how requests and responses move through the framework. + +## Usage guide + +See [`USAGE.md`](./USAGE.md) for a user guide that explains the app’s main features and workflows. diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..bb72ef9 --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,326 @@ +# KCI Campaign Tracker — User Guide + +This guide explains how to use the Campaign Tracker application day to day. + +## What the app does + +Campaign Tracker helps you manage: + +- **Campaign Types** — templates or categories for campaigns +- **Campaigns** — the main records you create and manage +- **Job Types** — templates or categories for jobs +- **Jobs** — work items connected to campaigns +- **Customer Types** — templates or categories for customers +- **Customers** — people or accounts tied to your workflow + +The dashboard gives you a quick overview of totals, recent items, and counts by type. + +--- + +## Getting started + +### Open the app + +In the default Docker setup, open: + +- `http://localhost:8801` + +### Sign in + +If authentication is enabled, click **Login** and sign in through the configured **Keycloak** server. + +If you are running locally outside Docker, make sure your Keycloak values are set in your environment first. + +--- + +## Main navigation + +The top navigation takes you to the main areas of the app: + +- **Home** — dashboard and quick stats +- **Campaigns** — manage campaigns +- **Campaign Types** — manage campaign definitions +- **Jobs** — manage all jobs +- **Job Types** — manage job definitions +- **Customers** — manage customer records +- **Customer Types** — manage customer definitions + +--- + +## Typical workflow + +A common setup flow looks like this: + +1. Create **Campaign Types** +2. Create **Job Types** +3. Create **Customer Types** +4. Create **Campaigns** +5. Add **Jobs** to campaigns +6. Create or update **Customers** + +You can work in a different order, but the type records usually come first because they define the available attributes for the main records. + +--- + +## Dashboard + +The home page shows: + +- totals for each entity type +- recent campaigns +- recent customers +- count breakdowns by type + +Use the dashboard to quickly jump into the area you need. + +--- + +## Campaign Types + +Use **Campaign Types** when you want to define reusable campaign templates. + +### What you can do + +- view all campaign types +- create a new campaign type +- edit an existing campaign type +- delete a campaign type + +### Why it matters + +Campaign types define the attributes that campaigns can use. For example, a campaign type might include fields like budget, owner, region, or status. + +--- + +## Campaigns + +Use **Campaigns** to create and manage the main campaign records. + +### What you can do + +- view the campaign directory +- create a new campaign +- edit campaign details +- delete a campaign +- open the campaign’s job list +- refresh the table + +### Campaign jobs + +Each campaign can have jobs attached to it. From the campaign view, you can: + +- see jobs for that campaign +- import jobs from Google Sheets +- import jobs from a file +- filter jobs by the selected campaign + +--- + +## Job Types + +Use **Job Types** to define reusable job templates. + +### What you can do + +- create a job type +- edit a job type +- delete a job type + +### Why it matters + +Job types define the attributes used when creating jobs. This makes it easier to keep job records consistent. + +--- + +## Jobs + +Use **Jobs** to track work items across campaigns. + +### What you can do + +- view all jobs in the directory +- create a new job +- edit a job +- delete a job +- refresh the jobs table + +### Campaign-specific jobs + +You can also open the job list for a single campaign to focus on just that campaign’s work. + +--- + +## Customer Types + +Use **Customer Types** to define reusable customer templates. + +### What you can do + +- create a customer type +- edit a customer type +- delete a customer type + +### Why it matters + +Customer types define the attributes available when you create customer records. + +--- + +## Customers + +Use **Customers** to manage customer or account records. + +### What you can do + +- view all customers in the directory +- create a new customer +- edit a customer +- delete a customer +- refresh the table + +--- + +## Data tables + +The list pages use interactive tables. + +Common table features include: + +- sorting +- searching +- row selection +- drill-down into related records +- refresh buttons + +If a table looks empty, try refreshing the page or the table itself. + +--- + +## Importing jobs from Google Sheets + +The app includes import tools for campaign jobs. + +### Basic flow + +1. Open a campaign +2. Open the campaign’s jobs page +3. Provide a Google Sheets URL +4. Choose the sheet and job type +5. Run the import + +### What the importer does + +The importer looks for sheet columns that match the attribute names on the selected job type. + +If the headers do not match, the import can fail or skip rows. + +### Tips + +- Make sure the Google Sheet is accessible +- Make sure the columns match the job type attributes +- Use consistent header names + +--- + +## Importing jobs from a file + +The campaign job page also supports file-based import. + +Typical steps: + +1. Open the campaign jobs page +2. Choose a file source +3. Pick the sheet or tab if needed +4. Choose the job type +5. Import the rows + +This is useful when the data already exists in a spreadsheet file instead of a published Google Sheet. + +--- + +## Public API endpoints + +The app exposes a few public JSON endpoints: + +- `GET /api/customers` +- `GET /api/customers/{id}` +- `GET /api/customer-types` + +There is also an authenticated proxy endpoint: + +- `GET /api/proxy?url=https://example.com/data.json` + +The proxy requires login and only accepts valid `http` or `https` URLs. + +--- + +## Health check + +You can verify the app is running with: + +- `GET /health` + +In the default Docker setup, that is: + +- `http://localhost:8801/health` + +--- + +## Common troubleshooting + +### Login does not work + +Check the Keycloak settings in your environment: + +- `KEYCLOAK_BASE_URL` +- `KEYCLOAK_REALM` +- `KEYCLOAK_CLIENT_ID` +- `KEYCLOAK_CLIENT_SECRET` +- `KEYCLOAK_REDIRECT_URI` +- `KEYCLOAK_LOGOUT_REDIRECT_URI` + +### Database errors on startup + +If migrations fail, wait a few seconds and try again. SQL Server may still be starting. + +### Empty or missing table data + +Run migrations and make sure the database is initialized: + +```bash +docker compose exec campaign-tracker-app php scripts/migrate.php up +``` + +### Import errors + +If an import fails, check: + +- campaign exists +- job type exists +- sheet URL is valid +- sheet headers match the selected type’s attributes + +--- + +## Setup summary for local development + +```bash +docker compose up -d --build +docker compose exec campaign-tracker-app composer install +docker compose exec campaign-tracker-app php scripts/migrate.php up +``` + +Then open: + +- `http://localhost:8801` + +--- + +## Where to look in the code + +- `routes/web.php` — route definitions +- `app/Controllers/` — request handling +- `app/Repositories/` — database access +- `app/Views/` — templates +- `database/migrations/` — schema setup +- `scripts/migrate.php` — migration CLI