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
- 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.
- Read-Mirror
tracker_issuesin 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. - Tracker-Abstraktion
issue_tracker/mitTrackerBackend-Protocol (list_issues,get_issue,add_comment,transition,attach,list_transitions). Erster AdapterJiraCloudBackend(REST v3, Service-Account + API-Token). Redmine ist ein zweiter Adapter, kein Umbau. - Projekt-Mapping:
projects.tracker_project_key(ein Linetra-Projekt ↔ ein Jira-Projekt). Issues ohne gemapptes Projekt werden nicht gespiegelt. - Personen-Mapping: Jira-
accountId↔personsübercontact_identities(channel='jira'). Personen sind Stammdaten (Tenant-DB), keine Web-Konten. - Zugangsdaten pro Mandant in
integration_connections(Tenant-DB), Secrets Fernet-verschlüsselt mitLINETRA_TENANT_SECRET. Nicht in.env. - Celery Beat wird eingeschaltet (hebt den Vorbehalt aus ADR-0011 auf). Queue
syncfür Tracker/Messaging, Queueassistantfür LLM-Jobs. - 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 aufbackend:8000;CsrfMiddlewarenimmt 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.