Skip to content

tuj/timemytime

Repository files navigation

Time My Time

Description

Time My Time is a planning and time registration tool written in PHP, with Symfony as framework.

Tech stack

Features

  • Projects, milestones, issues, worklogs.
  • Planning overview.
  • Data export through API Platform.
  • Users with different permission levels.
  • Audit log.
  • GDPR compliance.
  • Easy data anonymization handled by rules.

Approach to developing features

  • Follows TDD - Test Driven Development.
  • 100% test coverage.
  • Minimize frontend logic and dependencies.
  • Use an iterative approach.
  • Use soft deletable.
  • All changes should be tracked in an audit log.
  • Be GDPR-compliant.

Data structure

All entities have a ULID as ID, createdBy, modifiedBy, createdAt, updatedAt.

All entities should be archivable (not appearing unless "Show archived" is active).

Project

  • Title
  • Avatar
  • Status: Active, Inactive
  • Issues
  • Milestones

Milestone

  • Title
  • Description
  • Expected date of delivery
  • Delivery date (when Done)
  • Issues in the milestone
  • Status: Not Done, Done

Issue

  • Title
  • Description
  • Tags
  • Delivery
  • Attached worklogs

Worklog

  • Description
  • Started Datetime
  • Hours spent (float, .25 granularity)

Audit log

Every mutation of an entity inheriting App\Entity\AbstractEntity is recorded via damienharper/auditor-bundle. For each audited entity, the bundle creates a sibling <table>_audit table that captures inserts, updates, association changes, and the user who triggered them.

Soft-deletes ($em->remove() on a SoftDeletableInterface) are intercepted by App\Doctrine\Listener\SoftDeleteListener before the auditor runs, so they appear in the audit log as an update of deletedAt (null → timestamp), not as a remove. The full lifecycle of a soft-deleted row stays in history.

Auto-discovery

Audited entities are discovered at container compile time by App\DependencyInjection\AppExtension: every concrete class extending AbstractEntity under src/Entity/ is registered with the auditor automatically. A new domain entity is audited as soon as bin/console cache:clear runs — no config edits required. The same mechanism picks up tests/Fixtures/Entity/ in the test environment.

Reading the log

The auditor's DH\Auditor\Provider\Doctrine\Persistence\Reader\Reader service is autowirable. To list every change to a given entity class:

$audits = $reader->createQuery(Project::class)->execute();

Each entry exposes the action type (insert / update / remove / associate / dissociate), the user id, the timestamp, and a field-level getDiffs() array.

Retention & anonymization

Audit rows hold personal data (field diffs, IPs, blame display names) and must be aged out under GDPR Art. 5(1)(e). The retention policy is configured in config/packages/app_gdpr.yaml:

parameters:
    app.gdpr.audit_retention: P1Y                    # ISO-8601 duration, default
    app.gdpr.audit_retention_overrides:
        App\Entity\FinancialTransaction: P5Y         # per-entity overrides for
        App\Entity\KycVerification: P5Y              # legal-retention obligations

A sweep is run by:

bin/console app:gdpr:anonymize-stale --older-than=P2Y [--dry-run]

This does two things in one pass:

  1. Entity anonymization — rows where createdAt < now - --older-than get their #[Anonymize] fields scrubbed and anonymizedAt set. Idempotent.
  2. Audit retention sweep — for every registered audited class, audit rows older than the configured retention period have all old/new values nulled in their diffs JSON (field-key structure is preserved for "what changed when" analytics), and ip / blame_user / blame_user_fqdn / blame_user_firewall are nulled. blame_id, type, object_id, transaction_hash, and created_at are kept (structural, opaque ULIDs are not PII on their own).

Cron-friendly: schedule the command nightly with a chosen --older-than. The audit retention runs unconditionally on every invocation regardless of --older-than — keep the schedule cadence shorter than the retention window.

The right-to-erasure command (app:gdpr:anonymize <subjectId>) additionally nulls the ip column on every audit row where the subject was the actor (blame_id = subject.id), independent of retention age.

Testing audit retention behaviour

The auditor bundle writes created_at = new \DateTimeImmutable('now') inline (see DH\Auditor\Provider\Doctrine\Auditing\Transaction\TransactionProcessor); it doesn't accept an injectable ClockInterface, and the surrounding classes are final. Production is unaffected — both retention threshold and audit created_at use real wall-clock time. Tests, however, can't make audit rows "appear old" through MockClock alone.

Workaround: backdate audit rows manually after persist. See tests/Integration/Gdpr/AuditRetentionSweepTest::backdateAuditRows() for the helper pattern:

$this->conn->executeStatement(
    'UPDATE <table>_audit SET created_at = :when WHERE object_id = :oid',
    ['when' => '2024-01-01 00:00:00', 'oid' => (string) $entity->getId()],
);

A cleaner solution would require either an upstream PR adding ClockInterface support to the bundle, or replacing the whole TransactionManagerInterface chain in DI — both more invasive than the helper for the current testing surface.

About

Time planning and registration system.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors