docs(08): research phase domain
This commit is contained in:
parent
96d70dd160
commit
9cb09ec839
1 changed files with 443 additions and 0 deletions
443
.planning/phases/08-backend-aggregation-filtering/08-RESEARCH.md
Normal file
443
.planning/phases/08-backend-aggregation-filtering/08-RESEARCH.md
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
# Phase 8: Backend Aggregation + Filtering - Research
|
||||
|
||||
**Researched:** 2026-04-09
|
||||
**Domain:** Symfony/Doctrine PHP backend, SQL aggregation, timezone handling
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 8 extends the existing `HeatmapService` and `HeatmapController` with two new aggregation modes (hourly, day/hour) and three filter parameters (activity, customer, plus existing project). It also adds three cascade entity endpoints for the Phase 10 TomSelect pickers. All changes are backend PHP + PHPUnit tests with minimal frontend touch (URL parameter construction only).
|
||||
|
||||
The critical technical challenge is **timezone-correct hour extraction**. Kimai stores `start_time` in UTC but users operate in local timezones. MariaDB's `CONVERT_TZ` with numeric offsets (e.g., `'+02:00'`) is the most reliable approach -- it does not depend on MySQL timezone tables being loaded. The `date_tz` column (already in user timezone) can be used for day-of-week grouping.
|
||||
|
||||
**Primary recommendation:** Use native SQL queries (via DBAL Connection) for the hourly and day/hour aggregations since Kimai only registers DATE/DAY/MONTH/YEAR as custom DQL functions -- there is no HOUR function. Keep the existing DQL QueryBuilder pattern for the daily aggregation and cascade endpoints.
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
No locked decisions -- user deferred all to Claude's discretion.
|
||||
|
||||
### Claude's Discretion
|
||||
- D-01: Extend HeatmapService with separate getHourlyAggregation() and getDayHourAggregation() methods
|
||||
- D-02: Extend existing /heatmap/data endpoint with optional mode query param (daily/hourly/dayhour)
|
||||
- D-03: Add activity and customer query params to /heatmap/data endpoint with AND logic
|
||||
- D-04: Cascade endpoints: /heatmap/customers, /heatmap/projects?customer={id}, /heatmap/activities?project={id}
|
||||
- D-05: Cascade response format: [{id: number, name: string}]
|
||||
- D-06: Hourly aggregation returns {hour: 0-23, hours: float, count: int}
|
||||
- D-07: Day/hour aggregation returns {day: 0-6, hour: 0-23, hours: float, count: int}
|
||||
- D-08: Use Kimai user timezone for hour grouping
|
||||
- D-09: Frontend extends existing fetch with mode/filter params
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| API-01 | API endpoint accepts mode param returning hour-level and day/hour aggregation data | New service methods + mode param dispatch in controller (see Architecture Patterns) |
|
||||
| API-02 | API endpoint accepts activity and customer filter params with own controller endpoints | Filter params on /heatmap/data + 3 cascade endpoints (see Cascade Endpoints pattern) |
|
||||
| FILT-02 | Activity filtering narrows heatmap data to a specific activity | QueryBuilder WHERE on t.activity (see Filter Implementation pattern) |
|
||||
| FILT-03 | Customer filtering narrows heatmap data to all projects under a customer | JOIN through t.project -> p.customer (see Filter Implementation pattern) |
|
||||
| TEST-03 | PHPUnit tests for hour-level and day/hour aggregation queries | Extend existing mock pattern from HeatmapServiceTest (see Validation Architecture) |
|
||||
</phase_requirements>
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Changes Structure
|
||||
|
||||
```
|
||||
Controller/HeatmapController.php # extend with mode dispatch + cascade actions
|
||||
Service/HeatmapService.php # add 3 new methods + extend existing with filters
|
||||
Tests/Controller/HeatmapControllerTest.php # extend with new action tests
|
||||
Tests/Service/HeatmapServiceTest.php # extend with new method tests
|
||||
assets/src/types.ts # add HourEntry, DayHourEntry, HourlyData, DayHourData types
|
||||
```
|
||||
|
||||
### Pattern 1: Mode Dispatch in Controller
|
||||
|
||||
**What:** The existing `data()` action reads a `mode` query param and dispatches to the appropriate service method. [VERIFIED: existing controller code]
|
||||
|
||||
**Example:**
|
||||
```php
|
||||
// Source: extending existing HeatmapController.php
|
||||
#[Route(path: '/data', name: 'heatmap_data', methods: ['GET'])]
|
||||
#[IsGranted('view_own_timesheet')]
|
||||
public function data(Request $request, HeatmapService $service): JsonResponse
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$end = new \DateTimeImmutable('today');
|
||||
$begin = $end->modify('-1 year');
|
||||
|
||||
$mode = $request->query->getString('mode', 'daily');
|
||||
$projectId = $request->query->getInt('project') ?: null;
|
||||
$customerId = $request->query->getInt('customer') ?: null;
|
||||
$activityId = $request->query->getInt('activity') ?: null;
|
||||
|
||||
return match ($mode) {
|
||||
'hourly' => new JsonResponse([
|
||||
'hours' => $service->getHourlyAggregation($user, $begin, $end, $projectId, $customerId, $activityId),
|
||||
'range' => ['begin' => $begin->format('Y-m-d'), 'end' => $end->format('Y-m-d')],
|
||||
]),
|
||||
'dayhour' => new JsonResponse([
|
||||
'matrix' => $service->getDayHourAggregation($user, $begin, $end, $projectId, $customerId, $activityId),
|
||||
'range' => ['begin' => $begin->format('Y-m-d'), 'end' => $end->format('Y-m-d')],
|
||||
]),
|
||||
default => new JsonResponse([
|
||||
'days' => $service->getDailyAggregation($user, $begin, $end, $projectId, $customerId, $activityId),
|
||||
'range' => ['begin' => $begin->format('Y-m-d'), 'end' => $end->format('Y-m-d')],
|
||||
]),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Timezone-Correct Hour Extraction via Native SQL
|
||||
|
||||
**What:** Use DBAL native SQL with `CONVERT_TZ` for hour grouping since Kimai has no HOUR DQL function registered. [VERIFIED: doctrine.yaml only registers DATE/DAY/MONTH/YEAR]
|
||||
|
||||
**Why native SQL:** Kimai's custom DQL functions (in `App\Doctrine\Extensions\`) only cover DATE, DAY, MONTH, YEAR. Adding a plugin-level DQL function would require modifying Kimai's Doctrine config, which plugins should not do. Native SQL via DBAL Connection is clean and explicit.
|
||||
|
||||
**Critical timezone detail:** `start_time` is stored in UTC. Each timesheet has a `timezone` column. The user's current timezone is available via `$user->getTimezone()`. We compute a numeric offset string (e.g., `'+02:00'`) from the user timezone and use `CONVERT_TZ(t.start_time, '+00:00', :tz_offset)`. This avoids requiring MySQL timezone tables. [VERIFIED: UTCDateTimeType.php confirms UTC storage; Timesheet entity confirms timezone column]
|
||||
|
||||
**Example:**
|
||||
```php
|
||||
// Source: pattern derived from existing codebase analysis
|
||||
public function getHourlyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null): array
|
||||
{
|
||||
$tz = new \DateTimeZone($user->getTimezone());
|
||||
$offset = $tz->getOffset(new \DateTime('now', $tz));
|
||||
$offsetStr = sprintf('%+03d:%02d', intdiv($offset, 3600), abs($offset % 3600) / 60);
|
||||
|
||||
$conn = $this->repository->getEntityManager()->getConnection();
|
||||
$sql = 'SELECT HOUR(CONVERT_TZ(t.start_time, \'+00:00\', :tz)) as hour_slot,
|
||||
COALESCE(SUM(t.duration), 0) as duration,
|
||||
COUNT(t.id) as count
|
||||
FROM kimai2_timesheet t
|
||||
WHERE t.user = :user
|
||||
AND t.date_tz BETWEEN :begin AND :end
|
||||
AND t.end_time IS NOT NULL';
|
||||
|
||||
$params = [
|
||||
'tz' => $offsetStr,
|
||||
'user' => $user->getId(),
|
||||
'begin' => $begin->format('Y-m-d'),
|
||||
'end' => $end->format('Y-m-d'),
|
||||
];
|
||||
|
||||
// append optional filters...
|
||||
// $sql .= ' GROUP BY hour_slot ORDER BY hour_slot ASC';
|
||||
|
||||
$results = $conn->executeQuery($sql, $params)->fetchAllAssociative();
|
||||
// map to response format
|
||||
}
|
||||
```
|
||||
|
||||
**Offset caveat:** A single offset computed at request time is correct for users in fixed-offset timezones. For DST timezones, entries created during summer will be off by 1 hour when viewed in winter (and vice versa). This is an acceptable tradeoff for an aggregation heatmap -- Kimai's own `date_tz` column has the same limitation (stores the date at creation time, not retroactively adjusted). [ASSUMED]
|
||||
|
||||
### Pattern 3: Filter Implementation
|
||||
|
||||
**What:** Activity and customer filters are additive WHERE clauses on all aggregation queries. [VERIFIED: existing project filter pattern in HeatmapService]
|
||||
|
||||
**Customer filter requires a JOIN** since timesheets reference projects, and projects reference customers:
|
||||
```sql
|
||||
-- Activity filter (direct relation)
|
||||
AND t.activity_id = :activity
|
||||
|
||||
-- Customer filter (through project)
|
||||
INNER JOIN kimai2_projects p ON t.project_id = p.id
|
||||
AND p.customer_id = :customer
|
||||
```
|
||||
|
||||
For DQL (daily aggregation extending existing QueryBuilder):
|
||||
```php
|
||||
if ($activityId !== null) {
|
||||
$qb->andWhere($qb->expr()->eq('t.activity', ':activity'))
|
||||
->setParameter('activity', $activityId);
|
||||
}
|
||||
if ($customerId !== null) {
|
||||
$qb->join('t.project', 'p')
|
||||
->andWhere($qb->expr()->eq('p.customer', ':customer'))
|
||||
->setParameter('customer', $customerId);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Cascade Entity Endpoints
|
||||
|
||||
**What:** Three new controller actions returning entity lists scoped to user's own timesheets. [VERIFIED: getUserProjects() pattern in HeatmapService]
|
||||
|
||||
```php
|
||||
#[Route(path: '/customers', name: 'heatmap_customers', methods: ['GET'])]
|
||||
#[IsGranted('view_own_timesheet')]
|
||||
public function customers(HeatmapService $service): JsonResponse
|
||||
{
|
||||
return new JsonResponse($service->getUserCustomers($this->getUser()));
|
||||
}
|
||||
```
|
||||
|
||||
Service methods follow the existing `getUserProjects()` pattern -- query timesheets, join to related entity, select distinct, return `[{id, name}]`.
|
||||
|
||||
### Pattern 5: Day/Hour Aggregation with weekStart
|
||||
|
||||
**What:** The day/hour (punchcard) mode groups by day-of-week AND hour-of-day. Day index is relative to user's `weekStart` preference.
|
||||
|
||||
**Day-of-week from `date_tz`:** Since `date_tz` already stores the date in user timezone, `DAYOFWEEK(date_tz)` gives the correct day. MySQL's DAYOFWEEK returns 1=Sunday through 7=Saturday. We need to remap to 0-6 relative to weekStart in PHP. [VERIFIED: Timesheet entity doc comment confirms date_tz is in user timezone]
|
||||
|
||||
```sql
|
||||
SELECT DAYOFWEEK(t.date_tz) as dow,
|
||||
HOUR(CONVERT_TZ(t.start_time, '+00:00', :tz)) as hour_slot,
|
||||
COALESCE(SUM(t.duration), 0) as duration,
|
||||
COUNT(t.id) as count
|
||||
FROM kimai2_timesheet t
|
||||
WHERE ...
|
||||
GROUP BY dow, hour_slot
|
||||
ORDER BY dow, hour_slot
|
||||
```
|
||||
|
||||
Then in PHP, remap MySQL's DAYOFWEEK (1=Sun,2=Mon,...,7=Sat) to 0-6 relative to weekStart:
|
||||
```php
|
||||
// MySQL DAYOFWEEK: 1=Sun, 2=Mon, ... 7=Sat
|
||||
// If weekStart=monday: Mon=0, Tue=1, ..., Sun=6
|
||||
// If weekStart=sunday: Sun=0, Mon=1, ..., Sat=6
|
||||
$weekStartOffset = ($weekStart === 'sunday') ? 1 : 2;
|
||||
$dayIndex = ($mysqlDow - $weekStartOffset + 7) % 7;
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Registering custom DQL functions from a plugin:** Plugins should not modify Kimai's Doctrine configuration. Use native SQL instead. [VERIFIED: plugin architecture does not support custom DQL registration]
|
||||
- **Using `CONVERT_TZ` with named timezones:** Requires MySQL `mysql.time_zone_name` table to be populated (often empty in Docker/dev setups). Use numeric offsets only. [ASSUMED]
|
||||
- **Ignoring the `end IS NOT NULL` filter:** Running timesheets have no duration. Existing code correctly filters these. New queries must too. [VERIFIED: existing service code]
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Timezone offset calculation | Manual hour math | PHP DateTimeZone::getOffset() | Handles DST transitions correctly |
|
||||
| Day-of-week calculation | PHP date() on each row | MySQL DAYOFWEEK() on date_tz | Database-side grouping is faster |
|
||||
| SQL injection protection | String concatenation | Parameterized queries (DBAL params) | Standard security practice |
|
||||
| JSON serialization | Manual json_encode | Symfony JsonResponse | Handles encoding, content-type headers |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: CONVERT_TZ Returns NULL
|
||||
**What goes wrong:** `CONVERT_TZ` returns NULL when using named timezones (e.g., 'Europe/Berlin') and MySQL timezone tables are not loaded.
|
||||
**Why it happens:** MariaDB/MySQL ship with empty timezone tables by default. Named timezone lookups fail silently.
|
||||
**How to avoid:** Always use numeric offset format (`'+02:00'`), never named timezones.
|
||||
**Warning signs:** Aggregation returns zero/empty results when data exists. [ASSUMED]
|
||||
|
||||
### Pitfall 2: DST Offset Mismatch
|
||||
**What goes wrong:** A single offset computed at request time doesn't match offsets at the time entries were created. Summer entries shift by 1 hour when viewed in winter.
|
||||
**Why it happens:** Timezones with DST have different UTC offsets at different times of year.
|
||||
**How to avoid:** For a heatmap aggregation, this 1-hour shift is acceptable. If precision is needed later, each timesheet's own `timezone` column could be used per-row (expensive).
|
||||
**Warning signs:** Hour bins near DST transitions show unexpected clustering. [ASSUMED]
|
||||
|
||||
### Pitfall 3: Table Name Mismatch
|
||||
**What goes wrong:** Native SQL uses actual database table names, not Doctrine entity names.
|
||||
**Why it happens:** DQL uses entity names (`Timesheet`, `Project`), but raw SQL needs table names (`kimai2_timesheet`, `kimai2_projects`).
|
||||
**How to avoid:** Check the `@ORM\Table` annotation or use `$em->getClassMetadata(Timesheet::class)->getTableName()` to resolve dynamically.
|
||||
**Warning signs:** "Table doesn't exist" SQL errors. [VERIFIED: Timesheet entity uses kimai2_timesheet table]
|
||||
|
||||
### Pitfall 4: Customer Filter Without Project Join
|
||||
**What goes wrong:** Trying to filter by customer directly on the timesheet table fails -- there is no customer_id column on kimai2_timesheet.
|
||||
**Why it happens:** Kimai's entity model is Timesheet -> Project -> Customer (no direct Timesheet -> Customer relation).
|
||||
**How to avoid:** Always JOIN kimai2_projects when filtering by customer.
|
||||
**Warning signs:** SQL column not found error. [VERIFIED: Timesheet entity has project and activity relations only]
|
||||
|
||||
### Pitfall 5: Missing Query Builder Methods in Mock
|
||||
**What goes wrong:** PHPUnit tests fail because the mock QueryBuilder doesn't stub new chained methods (e.g., `join()`).
|
||||
**Why it happens:** Existing test helper `createServiceWithResults()` stubs a fixed set of QB methods. New filter logic adds `join()`.
|
||||
**How to avoid:** Add `$qb->method('join')->willReturnSelf()` to the test helper. For native SQL tests, mock the DBAL Connection instead.
|
||||
**Warning signs:** "Call to undefined method" in test output. [VERIFIED: existing test mock pattern]
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Native SQL Query with DBAL Connection
|
||||
|
||||
```php
|
||||
// Source: Doctrine DBAL pattern [VERIFIED: TimesheetRepository uses getEntityManager()]
|
||||
$conn = $this->repository->getEntityManager()->getConnection();
|
||||
$result = $conn->executeQuery($sql, $params);
|
||||
$rows = $result->fetchAllAssociative();
|
||||
```
|
||||
|
||||
### Timezone Offset from User
|
||||
|
||||
```php
|
||||
// Source: PHP DateTimeZone API [VERIFIED: User::getTimezone() returns timezone string]
|
||||
$tz = new \DateTimeZone($user->getTimezone());
|
||||
$offset = $tz->getOffset(new \DateTime('now', new \DateTimeZone('UTC')));
|
||||
$hours = intdiv($offset, 3600);
|
||||
$minutes = abs(intdiv($offset % 3600, 60));
|
||||
$offsetStr = sprintf('%+03d:%02d', $hours, $minutes);
|
||||
// e.g., "Europe/Berlin" in summer -> "+02:00"
|
||||
```
|
||||
|
||||
### Cascade Query (getUserCustomers example)
|
||||
|
||||
```php
|
||||
// Source: extending existing getUserProjects() pattern [VERIFIED: HeatmapService.php]
|
||||
public function getUserCustomers(User $user): array
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('t');
|
||||
$qb->select('DISTINCT IDENTITY(p.customer) as customerId, c.name')
|
||||
->join('t.project', 'p')
|
||||
->join('p.customer', 'c')
|
||||
->andWhere($qb->expr()->eq('t.user', ':user'))
|
||||
->andWhere($qb->expr()->isNotNull('t.end'))
|
||||
->setParameter('user', $user)
|
||||
->orderBy('c.name', 'ASC');
|
||||
|
||||
return array_map(fn(array $row) => [
|
||||
'id' => (int) $row['customerId'],
|
||||
'name' => $row['name'],
|
||||
], $qb->getQuery()->getResult());
|
||||
}
|
||||
```
|
||||
|
||||
### getUserActivities with Optional Project Scope
|
||||
|
||||
```php
|
||||
// Source: extending getUserProjects() pattern [VERIFIED: HeatmapService.php]
|
||||
public function getUserActivities(User $user, ?int $projectId = null): array
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('t');
|
||||
$qb->select('DISTINCT IDENTITY(t.activity) as activityId, a.name')
|
||||
->join('t.activity', 'a')
|
||||
->andWhere($qb->expr()->eq('t.user', ':user'))
|
||||
->andWhere($qb->expr()->isNotNull('t.end'))
|
||||
->setParameter('user', $user)
|
||||
->orderBy('a.name', 'ASC');
|
||||
|
||||
if ($projectId !== null) {
|
||||
$qb->andWhere($qb->expr()->eq('t.project', ':project'))
|
||||
->setParameter('project', $projectId);
|
||||
}
|
||||
|
||||
return array_map(fn(array $row) => [
|
||||
'id' => (int) $row['activityId'],
|
||||
'name' => $row['name'],
|
||||
], $qb->getQuery()->getResult());
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Native SQL with Mocked Connection
|
||||
|
||||
```php
|
||||
// Source: PHPUnit mock pattern for DBAL [ASSUMED]
|
||||
private function createServiceWithNativeResults(array $results): HeatmapService
|
||||
{
|
||||
// For native SQL methods, mock the DBAL connection
|
||||
$statement = $this->createMock(\Doctrine\DBAL\Result::class);
|
||||
$statement->method('fetchAllAssociative')->willReturn($results);
|
||||
|
||||
$connection = $this->createMock(\Doctrine\DBAL\Connection::class);
|
||||
$connection->method('executeQuery')->willReturn($statement);
|
||||
|
||||
$em = $this->createMock(\Doctrine\ORM\EntityManagerInterface::class);
|
||||
$em->method('getConnection')->willReturn($connection);
|
||||
|
||||
$repo = $this->createMock(TimesheetRepository::class);
|
||||
$repo->method('getEntityManager')->willReturn($em);
|
||||
// Also set up createQueryBuilder for DQL methods
|
||||
// ...
|
||||
|
||||
return new HeatmapService($repo);
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | PHPUnit 10.5 |
|
||||
| Config file | `Tests/phpunit.xml` |
|
||||
| Quick run command | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml` |
|
||||
| Full suite command | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml` |
|
||||
|
||||
### Phase Requirements -> Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| API-01 | Mode param dispatches to correct service method | unit | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter testDataReturnsHourly` | Wave 0 |
|
||||
| API-01 | Hourly aggregation groups by hour, returns correct shape | unit | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter testGetHourlyAggregation` | Wave 0 |
|
||||
| API-01 | Day/hour aggregation groups by day+hour, returns correct shape | unit | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter testGetDayHourAggregation` | Wave 0 |
|
||||
| API-02 | Activity filter param narrows results | unit | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter testActivityFilter` | Wave 0 |
|
||||
| API-02 | Customer filter param narrows results via project join | unit | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter testCustomerFilter` | Wave 0 |
|
||||
| API-02 | Cascade endpoints return entity lists | unit | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter testCustomersEndpoint` | Wave 0 |
|
||||
| FILT-02 | Activity filter applied to all aggregation modes | unit | covered by API-02 activity filter tests | Wave 0 |
|
||||
| FILT-03 | Customer filter applied to all aggregation modes | unit | covered by API-02 customer filter tests | Wave 0 |
|
||||
| TEST-03 | PHPUnit tests for aggregation queries | unit | full suite command | Wave 0 |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml`
|
||||
- **Per wave merge:** same (single test suite)
|
||||
- **Phase gate:** Full suite green before `/gsd-verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
None -- existing test infrastructure (`Tests/phpunit.xml`, `Tests/bootstrap.php`, mock helpers in `HeatmapServiceTest.php`) covers all needs. New test methods extend existing test classes.
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | no | Handled by Kimai (session auth) |
|
||||
| V3 Session Management | no | Handled by Kimai |
|
||||
| V4 Access Control | yes | `#[IsGranted('view_own_timesheet')]` + user-scoped queries |
|
||||
| V5 Input Validation | yes | Query param type casting (`getInt()`, `getString()`) + parameterized SQL |
|
||||
| V6 Cryptography | no | No crypto in this phase |
|
||||
|
||||
### Known Threat Patterns
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| SQL injection via filter params | Tampering | Parameterized queries (DBAL params for native SQL, QueryBuilder setParameter for DQL) |
|
||||
| IDOR on customer/project/activity IDs | Information Disclosure | All queries scoped to `t.user = :user` -- user can only see their own entities |
|
||||
| Mode param injection | Tampering | `match()` with `default` case prevents invalid modes from reaching queries |
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | CONVERT_TZ with numeric offsets works without timezone tables loaded | Pitfall 1 | Hour grouping returns NULL; fallback would be PHP-side processing |
|
||||
| A2 | Single offset for DST timezones is acceptable for heatmap aggregation | Pitfall 2 | 1-hour shift in some entries; low visual impact |
|
||||
| A3 | DBAL Result::fetchAllAssociative() is mockable in PHPUnit | Code Examples | Tests need different mock approach if class is final |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Table name resolution in native SQL**
|
||||
- What we know: Timesheet table is `kimai2_timesheet`, projects is `kimai2_projects`, customers is `kimai2_customers`, activities is `kimai2_activities`
|
||||
- What's unclear: Whether these names are configurable via Doctrine prefix
|
||||
- Recommendation: Use `$em->getClassMetadata(Timesheet::class)->getTableName()` for safety, or hardcode since Kimai's table names are stable [ASSUMED]
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- `Controller/HeatmapController.php` -- existing endpoint pattern, route attributes, auth checks
|
||||
- `Service/HeatmapService.php` -- existing QueryBuilder pattern, getUserProjects() shape
|
||||
- `Tests/Service/HeatmapServiceTest.php` -- existing mock pattern for service tests
|
||||
- `dev/kimai/config/packages/doctrine.yaml` -- confirmed DQL functions limited to DATE/DAY/MONTH/YEAR
|
||||
- `dev/kimai/src/Doctrine/UTCDateTimeType.php` -- confirmed UTC storage
|
||||
- `dev/kimai/src/Entity/Timesheet.php` -- confirmed entity relations, date_tz column, timezone column
|
||||
- `dev/kimai/src/Entity/User.php` -- confirmed getTimezone() and getFirstDayOfWeek() APIs
|
||||
- `dev/kimai/src/Entity/Project.php` -- confirmed customer relation
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- UI-SPEC (`08-UI-SPEC.md`) -- API response contracts for downstream phases
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- MariaDB CONVERT_TZ behavior with numeric offsets (not verified against running instance)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH -- extending existing PHP patterns, no new libraries
|
||||
- Architecture: HIGH -- all patterns verified against existing codebase
|
||||
- Pitfalls: MEDIUM -- timezone handling assumptions need runtime validation
|
||||
|
||||
**Research date:** 2026-04-09
|
||||
**Valid until:** 2026-05-09 (stable -- Kimai internals unlikely to change)
|
||||
Loading…
Add table
Reference in a new issue