Skip to content

ADR-0059: Issue-Tracker als Aufgaben-SSOT, Read-Mirror in Linetra

  • Status: proposed
  • Datum: 2026-10-02
  • Phase: Prozessmanagement
  • Bezug: ADR-0007, ADR-0011, ADR-0031
  • Ändert: ADR-0007 („kein Jira-Sync“ wird abgelöst; Kunden-Tickets bleiben)

Kontext

Der Projektmanager pflegt Aufgaben, Verantwortliche, Meilensteine und Fristen in Jira. Kunden bekommen dort eigene Projekte, die nur sie sehen. Linetra soll diese Aufgaben lesen, an Feldmitarbeiter verteilen (ADR-0060), Rückmeldungen zuordnen (ADR-0061) und Dateien anhängen (ADR-0062). ADR-0007 hatte Jira bewusst getrennt gehalten; das Nicht-Ziel „Jira-Ablöse“ bleibt, der Nicht-Sync nicht.

Ein Mandant (die Betreiberfirma) mit einer Jira-Instanz. Ein späterer Wechsel auf Redmine ist denkbar (self-hosted, keine Lizenz pro Kunde).

Entscheidung

  1. Jira bleibt SSOT für Aufgaben. Linetra legt keine Aufgaben an und ändert keine Felder außer Status (Transition), Kommentaren und Anhängen. Jede Schreiboperation geht über ADR-0061 (Policy-Gate) oder eine explizite Nutzeraktion in der Webapp.
  2. Read-Mirror tracker_issues in der Mandanten-DB. Quelle: Tracker-Webhooks (jira:issue_created, jira:issue_updated, comment_created) plus periodischer Poll (updated >= -15m, Celery Beat) als Reconciliation. Webhooks sind Beschleuniger, der Poll ist die Wahrheit.
  3. Tracker-Abstraktion issue_tracker/ mit TrackerBackend-Protocol (list_issues, get_issue, add_comment, transition, attach, list_transitions). Erster Adapter JiraCloudBackend (REST v3, Service-Account + API-Token). Redmine ist ein zweiter Adapter, kein Umbau.
  4. Projekt-Mapping: projects.tracker_project_key (ein Linetra-Projekt ↔ ein Jira-Projekt). Issues ohne gemapptes Projekt werden nicht gespiegelt.
  5. Personen-Mapping: Jira-accountId ↔ persons über contact_identities(channel='jira'). Personen sind Stammdaten (Tenant-DB), keine Web-Konten.
  6. Zugangsdaten pro Mandant in integration_connections (Tenant-DB), Secrets Fernet-verschlüsselt mit LINETRA_TENANT_SECRET. Nicht in .env.
  7. Celery Beat wird eingeschaltet (hebt den Vorbehalt aus ADR-0011 auf). Queue sync für Tracker/Messaging, Queue assistant für LLM-Jobs.
  8. Kunden-Tickets (project_tickets, ADR-0007) bleiben kundenseitig. Optional später: Ticket → Issue anlegen. Nicht Teil dieser Entscheidung.

Datenmodell (Tenant-DB linetra_<slug>)

CREATE TABLE integration_connections (
  connection_id  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  provider       text NOT NULL,                 -- 'jira' | 'redmine' | 'whatsapp' | 'nextcloud'
  label          text NOT NULL,
  base_url       text NOT NULL,
  config         jsonb NOT NULL DEFAULT '{}',   -- nicht geheim: cloud_id, phone_number_id, root_path …
  secret_enc     bytea NOT NULL,                -- Fernet: api_token, access_token, app_password
  is_active      boolean NOT NULL DEFAULT true,
  last_ok_at     timestamptz,
  last_error     text,
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now(),
  UNIQUE (provider, label)
);

CREATE TABLE contact_identities (
  identity_id  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  person_id    uuid NOT NULL REFERENCES persons(person_id) ON DELETE CASCADE,
  channel      text NOT NULL,                   -- 'jira' | 'whatsapp' | 'email'
  external_id  text NOT NULL,                   -- accountId | wa_id (E.164 ohne '+') | mail
  verified_at  timestamptz,
  opt_in_at    timestamptz,                     -- nur Messaging-Kanäle
  revoked_at   timestamptz,
  UNIQUE (channel, external_id)
);

ALTER TABLE projects ADD COLUMN tracker_project_key text;      -- 'LIN' | Redmine project identifier
ALTER TABLE projects ADD COLUMN briefing_time time;           -- lokal, NULL = kein Briefing
ALTER TABLE projects ADD COLUMN report_time   time;           -- lokal, NULL = kein Tagesbericht

CREATE TABLE tracker_issues (
  issue_id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  connection_id       uuid NOT NULL REFERENCES integration_connections(connection_id),
  project_id          uuid NOT NULL REFERENCES projects(project_id),
  external_id         text NOT NULL,            -- Jira id | Redmine id
  issue_key           text NOT NULL,            -- 'LIN-42' | '#4711'
  summary             text NOT NULL,
  description         text,
  issue_type          text,
  status              text NOT NULL,
  status_category     text NOT NULL,            -- 'todo' | 'in_progress' | 'done'
  priority            text,
  assignee_account_id text,
  assignee_person_id  uuid REFERENCES persons(person_id),
  reporter_account_id text,
  parent_key          text,
  labels              text[] NOT NULL DEFAULT '{}',
  due_date            date,
  tracker_updated_at  timestamptz NOT NULL,
  raw                 jsonb NOT NULL,
  synced_at           timestamptz NOT NULL DEFAULT now(),
  UNIQUE (connection_id, external_id)
);
CREATE INDEX ON tracker_issues (project_id, status_category, due_date);
CREATE INDEX ON tracker_issues (assignee_person_id, status_category);

CREATE TABLE tracker_sync_runs (
  run_id        uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  connection_id uuid NOT NULL REFERENCES integration_connections(connection_id),
  trigger       text NOT NULL,                  -- 'webhook' | 'poll' | 'manual'
  started_at    timestamptz NOT NULL DEFAULT now(),
  finished_at   timestamptz,
  issues_seen   integer NOT NULL DEFAULT 0,
  issues_upserted integer NOT NULL DEFAULT 0,
  error         text
);

Control-DB linetra

-- Tracker-Webhook trifft ohne Session ein; der Pfad-Token löst den Mandanten auf.
CREATE TABLE webhook_endpoints (
  endpoint_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   uuid NOT NULL REFERENCES tenants(tenant_id) ON DELETE CASCADE,
  provider    text NOT NULL,                    -- 'jira' | 'whatsapp'
  path_token  text NOT NULL UNIQUE,             -- /api/v1/webhooks/{provider}/{path_token}
  secret_enc  bytea,                            -- Signatur-Secret (Jira: Webhook-Secret)
  created_at  timestamptz NOT NULL DEFAULT now()
);

Die Alembic-Revisionen (Control 0041_…, Tenant 0026_…) entstehen mit den Modellen in Phase 1; DDL hier ist der Vertrag, nicht die Migration.

Begründung

Zwei Aufgabenbestände wären die „zweite Uhr“, die ADR-0007 vermeiden wollte. Ein Mirror ist kein zweiter Bestand: Er hat keinen eigenen Lebenszyklus, Linetra kann ihn jederzeit aus dem Tracker neu aufbauen. Webhooks allein reichen nicht (keine Zustellgarantie, Ausfall des Backends), Polling allein ist zu träge für „Mitarbeiter schreibt, Linetra antwortet“. Die Abstraktion kostet eine Datei und verhindert, dass jira als Begriff in Tabellen, Routen und UI einzementiert wird.

Verworfene Alternativen

Alternative Warum verworfen
Linetra-Aufgaben als Hauptbestand, Jira-Export PM arbeitet in Jira; Kunden haben dort ihre Projekte. Lastenheft: Jira-Ablöse ist Nicht-Ziel
Jira live abfragen ohne Mirror Briefing für 30 Leute = 30 JQL-Calls morgens; Rate-Limits; Tagesbericht braucht Historie
Nur Webhooks Keine Zustellgarantie; Backend-Downtime verliert Events
OAuth 2.0 (3LO) statt API-Token Ein Mandant, eine Instanz; 3LO erst, wenn fremde Jira-Instanzen angebunden werden
Jira-Kunden-Portal (JSM) statt Linetra-Tickets Kunden sollen keine Jira-Accounts brauchen (ADR-0007)
Spalten jira_* Blockiert Redmine; tracker_* ist neutral

Folgen

  • ADR-0007 Status: „kein Sync in P6“ gilt nicht mehr; Tickets bleiben kundenseitig.
  • ADR-0011: Celery Beat läuft ab jetzt; eigener Compose-Service celery-beat.
  • Traefik: PathPrefix(/api/v1/webhooks) direkt auf backend:8000; CsrfMiddleware nimmt diesen Prefix aus (Signatur ersetzt CSRF).
  • Service-Account in Jira braucht Browse / Add Comments / Transition / Create Attachments auf allen gemappten Projekten.
  • Neue Settings-Route /settings/integrations (Org-Admin), neue Route /operations/tasks (Mirror-Ansicht).
  • Roadmap und Lastenheft: neue Phase „Prozessmanagement“ mit IDs ist nachzuziehen.