diff --git a/CLAUDE.md b/CLAUDE.md index 7b05780..42570dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -359,6 +359,28 @@ notifications: id, user_id, title, body, link, is_read, created_at, issue_id, notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency ``` +### IssueLink (Phase 54) + +``` +issue_links: id, issue_id (FK→issues CASCADE, indexed), + linked_issue_id (FK→issues CASCADE, indexed), + link_type ENUM('duplicate','related') NOT NULL DEFAULT 'related', + created_by (FK→users SET NULL), created_at + UniqueConstraint(issue_id, linked_issue_id) — uq_issue_link +``` + +**Connects a duplicate to its original, or two issues about the same thing**, so whoever picks one up can reach the other. + +**One row is stored per pair and shown on BOTH issues.** The stored direction carries meaning for `duplicate` — `issue_id` is a duplicate *of* `linked_issue_id` — so the same row reads differently at each end: "Duplicate of #B" on one, "Duplicated by #A" on the other. `related` is symmetric and reads "Related to" from either side. `IssueLink.LABELS` is keyed `(link_type, is_source)` and is the only place that wording lives; `label_for(viewing_issue_id)` / `other_issue(viewing_issue_id)` resolve a row against whichever issue is on screen. + +Storing one row rather than a mirrored pair keeps the direction unambiguous and makes unlinking a single delete. The cost: **uniqueness cannot be expressed by the UniqueConstraint alone.** `(A,B)` and `(B,A)` are distinct rows to MySQL but the same link to a person, so **`IssueLink.exists_between(a, b)` is the only correct duplicate check** — it looks both ways. The constraint catches the exact-duplicate row; `exists_between()` catches the reverse. + +**A link is PURELY NAVIGATIONAL** (decided Sep 2026). Marking a duplicate does **not** touch either issue's status, `resolved_at`, SLA, assignee or followers, and fires no notification. Closing the duplicate stays a separate, deliberate action. Do not add side effects here without saying so in the UI — the link control reads as navigation, and a status write from it would be invisible. + +**Two FKs from one table to `issues`, so both relationships pin `foreign_keys`** — rule 86's failure mode, which raises on first ORM *use*, not at import. `Issue.links_from` / `Issue.links_to` are the two storage directions; **`Issue.all_links()` merges them** into the single list a person actually sees. Both relationships cascade `all, delete-orphan` (and both FKs are `ON DELETE CASCADE`), so deleting an issue takes its links from *either* end — a surviving link would render a dead row on the other issue's page. + +**Scope is the thing to get right here — see rule 99.** + ### IssueComment ``` @@ -621,7 +643,7 @@ Management of the underlying routes is otherwise unchanged; **Start** is the **a | `customers` | `/customers` | **Owns BOTH customer roles (Phase 51).** `GET /` list (both roles, role badge + per-role scope column), `GET/POST /new` invite (role select: Customer Director / Customer Inspector — same invitation flow for both), `/set-password/`, `GET /` manage, `//edit`, `POST //assignments/add` + `/assignments//remove` (**director only** — `CustomerAssignment`), `POST //contracts` (**inspector only** — replaces the whole `InspectorAssignment` set, rule 59 semantics), `POST //notifications` (per-account matrix overrides), `POST //switch-role` (**admin only** — mirrors contracts across, revokes tokens/devices), `POST //toggle-active`, `POST //resend-invite`, import CSV | | `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX), **`POST /bulk`** (bulk export-PDF / request-follow-up / clear-follow-up / delete from the list) | | `templates` | `/templates` | list, create, edit, delete, form editor, preview | -| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign, **`POST /bulk`** (bulk assign / status / verify / delete from the list). **verify / bulk-verify / verification-queue are `@issue_manager_required` (admin/director/auditor); delete stays `@supervisor_required` (admin/director).** | +| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign, **issue links** (`POST //links` add, `POST //links//delete` remove, `GET //link-search` scoped JSON picker — Phase 54), **`POST /bulk`** (bulk assign / status / verify / delete from the list). **verify / bulk-verify / verification-queue are `@issue_manager_required` (admin/director/auditor); delete stays `@supervisor_required` (admin/director).** | | `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron) | | `audit` | `/audit` | list (admin only), view, purge | | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | @@ -1045,7 +1067,24 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif → phase50_default_modern → phase51_user_notif_matrix → phase52_template_contracts - → phase53_followup_assignee ← HEAD + → phase53_followup_assignee + → phase54_issue_links ← HEAD + +#### phase54 — link related and duplicate issues + +Revision id `phase54_issue_links`. Creates `issue_links` — see §5 `IssueLink`. + +**Purely additive.** Nothing reads the table until a person creates a link, so an empty table is exactly today's behaviour and there is nothing to backfill. + +Both issue FKs are `ON DELETE CASCADE`, so a direct SQL delete of an issue cannot leave a link pointing at a row that no longer exists (the ORM cascade on `links_from`/`links_to` covers the application path). The index names deliberately match what SQLAlchemy's `index=True` generates — `ix_issue_links_issue_id`, `ix_issue_links_linked_issue_id` — so the schema this migration builds is identical to the one `db.create_all()` builds, down to the index names. + +Table-existence check — safe to re-run. `downgrade()` drops the table, discarding every link; no issue is affected, since a link never held state belonging to one. + +**Deploy order:** +```bash +flask db upgrade +sudo systemctl restart gunicorn +``` #### phase53 — assign a follow-up to another inspector @@ -1755,6 +1794,7 @@ timeout = 30 | 92 | **Bulk deletes: DB rows first, storage files second** | Collect the keys, `db.session.delete()` every row, `commit()`, and only then `storage.delete()`. Deleting files first means a failed/rolled-back commit leaves surviving rows pointing at missing photos. `_collect_inspection_photos()` is shared by the single and bulk inspection delete paths precisely so the two cannot drift — a key missed there is an invisible permanent storage leak. | | 89 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | The inverse of rule 87, and it fails in both directions. Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fail-OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them (fail-closed, but invisible until someone looks for a missing account). `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query, its route guards, the `auth.list_users` exclusion, **the customer-facing support surface** (`_is_customer_side()` — both roles get the same door, then branch per role for scope and for the AI's system prompt), and **narrowing** uses that WITHHOLD something from an external account (`_assignable_staff_for()` uses it to hide our internal staff — safe direction, and commented as such). Everything else — portal gates, `@customer_required`, `get_customer_scope()`, `notify_customers_for_facility()`, the customer branch of every `app/api/*` scope check — keeps the equality test, because a Customer Inspector is an **inspector** there (rule 87 already routes it correctly). | | 90 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that early `continue` has to also ask whether anyone opted in (`any(overrides.values())`), or the override saves, displays as on, and never sends — a silent failure with no error anywhere. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed; either one alone leaves a hole. See §11. | +| 99 | **An issue link is a pointer to another issue — filter it by scope on ALL THREE surfaces** | A link exposes the far issue's id, description, facility and status, so an unfiltered panel lets a customer read an issue at a facility they hold no assignment to, simply because one of our staff linked it. Three surfaces have to hold the line and only one of them is a real boundary: `_readable_links()` filters what the panel RENDERS, `link_search()` scopes what the picker FINDS, and `add_link()` re-checks on POST — the search is a convenience and must never be trusted as the gate. All three resolve scope through `_viewer_facility_scope()` / `_issue_in_scope()`, the same pair `issues.view()` now uses, so the panel cannot end up more permissive than the page it sits on (the rule 93 lesson, applied before it could bite). `_issue_in_scope` takes a resolved scope rather than a user so filtering a list costs one assignment query, not one per row. A link to an issue outside your scope reports "not found", never "access denied" — whether another customer's issue exists is not something the link box should confirm. | | 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues//photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. | --- diff --git a/app/models/issue.py b/app/models/issue.py index 278575f..27e935a 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -43,6 +43,116 @@ class IssueFollower(db.Model): return f'' +# ── Issue Link ──────────────────────────────────────────────────────────────── +# Connects two issues so staff can jump between a duplicate and the original, or +# between issues that are simply about the same thing. + +class IssueLink(db.Model): + """One directed link between two issues, displayed on BOTH of them. + + Only one row is stored per pair. The stored direction carries meaning for + 'duplicate' — issue_id is a duplicate OF linked_issue_id — so the two issues + read the same row differently: + + on issue_id -> "Duplicate of #B" + on linked_issue_id -> "Duplicated by #A" + + 'related' is symmetric and reads "Related to" from either side. + + Storing one row rather than a mirrored pair is what keeps the direction + unambiguous and makes unlinking a single delete. The cost is that uniqueness + cannot be expressed by the UniqueConstraint alone: (A,B) and (B,A) are + distinct rows to the database but the same link to a person, so the + duplicate check has to look in both directions. exists_between() is that + check, and it is the only thing callers should use. + + A link is PURELY NAVIGATIONAL. Marking a duplicate does not touch either + issue's status, SLA, assignee or followers — closing the duplicate stays a + deliberate, separate action. + """ + __tablename__ = 'issue_links' + + TYPE_DUPLICATE = 'duplicate' + TYPE_RELATED = 'related' + + # How each link type reads from the two sides, keyed by (type, is_source). + LABELS = { + ('duplicate', True): 'Duplicate of', + ('duplicate', False): 'Duplicated by', + ('related', True): 'Related to', + ('related', False): 'Related to', + } + + # Offered in the "Link an issue" picker. The value is what gets stored; the + # phrasing is from the point of view of the issue being viewed. + TYPE_CHOICES = [ + ('duplicate', 'Duplicate of'), + ('related', 'Related to'), + ] + + id = db.Column(db.Integer, primary_key=True) + issue_id = db.Column(db.Integer, + db.ForeignKey('issues.id', ondelete='CASCADE'), + nullable=False, index=True) + linked_issue_id = db.Column(db.Integer, + db.ForeignKey('issues.id', ondelete='CASCADE'), + nullable=False, index=True) + link_type = db.Column(db.Enum('duplicate', 'related'), + nullable=False, default='related') + created_by = db.Column(db.Integer, + db.ForeignKey('users.id', ondelete='SET NULL'), + nullable=True) + created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + + __table_args__ = ( + # Catches the exact-duplicate row at the database level. The REVERSE + # direction is caught by exists_between() — see the class docstring. + db.UniqueConstraint('issue_id', 'linked_issue_id', name='uq_issue_link'), + ) + + # BOTH relationships must pin foreign_keys: two FKs from this table to + # issues leave the join condition ambiguous otherwise, and the mapper raises + # on first ORM USE rather than at import — the app starts cleanly and then + # every request 500s (CLAUDE.md rule 86). + issue = db.relationship('Issue', foreign_keys=[issue_id], + back_populates='links_from') + linked_issue = db.relationship('Issue', foreign_keys=[linked_issue_id], + back_populates='links_to') + creator = db.relationship('User', foreign_keys=[created_by]) + + def label_for(self, viewing_issue_id): + """How this link reads on the issue currently being viewed.""" + return self.LABELS[(self.link_type, self.issue_id == viewing_issue_id)] + + def other_issue(self, viewing_issue_id): + """The issue at the far end of this link from the one being viewed.""" + return (self.linked_issue if self.issue_id == viewing_issue_id + else self.issue) + + @staticmethod + def exists_between(issue_id, other_id): + """True when the two issues are already linked, in EITHER direction. + + The UniqueConstraint only covers the stored direction, so this is what + stops #A being linked to #B and then #B linked back to #A as a second, + contradictory row. + """ + return db.session.query( + IssueLink.query.filter( + db.or_( + db.and_(IssueLink.issue_id == issue_id, + IssueLink.linked_issue_id == other_id), + db.and_(IssueLink.issue_id == other_id, + IssueLink.linked_issue_id == issue_id), + ) + ).exists() + ).scalar() + + def __repr__(self): + return (f'') + + class Issue(db.Model): __tablename__ = 'issues' @@ -115,10 +225,36 @@ class Issue(db.Model): followers = db.relationship('IssueFollower', back_populates='issue', cascade='all, delete-orphan', lazy='dynamic') + # An issue link is stored once and shown on both issues, so each issue has + # rows pointing OUT of it and rows pointing AT it. Deleting an issue must + # take its links with it from BOTH sides, or the surviving issue keeps a row + # referencing one that no longer exists. + links_from = db.relationship('IssueLink', back_populates='issue', + foreign_keys='IssueLink.issue_id', + cascade='all, delete-orphan', lazy='dynamic') + links_to = db.relationship('IssueLink', back_populates='linked_issue', + foreign_keys='IssueLink.linked_issue_id', + cascade='all, delete-orphan', lazy='dynamic') + def is_followed_by(self, user): """Return True if the given user is currently following this issue.""" return self.followers.filter_by(user_id=user.id).first() is not None + def all_links(self): + """Every link touching this issue, from both directions, newest first. + + The two relationships are a storage detail — a link is one thing to the + person reading it, so callers get a single list and ask each row how it + reads via label_for() / other_issue(). + + Nothing here filters by permission. The caller MUST drop links whose far + end the viewer cannot access, or a link becomes a way to read another + customer's issue. See _readable_links() in routes/issues.py. + """ + links = list(self.links_from) + list(self.links_to) + links.sort(key=lambda link: link.created_at, reverse=True) + return links + # Human-readable label for the handler category (phase35). # Perspective-neutral wording so it reads the same for staff and customers. HANDLER_LABELS = { diff --git a/app/routes/issues.py b/app/routes/issues.py index e481715..4800cdb 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -6,7 +6,7 @@ from flask import (Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify, abort, Response) from flask_login import login_required, current_user from app import db -from app.models.issue import Issue, IssueComment, IssueFollower +from app.models.issue import Issue, IssueComment, IssueFollower, IssueLink from app.models.facility import Facility, Area from app.models.user import User from app.models.notification import ( @@ -85,6 +85,44 @@ class _SLAFilteredPage: +# ── Issue read access ───────────────────────────────────────────────────────── +# One definition of "may this person open this issue", used by the detail view, +# by the linked-issues panel, and by the link picker's search. They must not +# drift: the picker is what a person searches, but the panel is what actually +# renders another issue's description, and the POST is the real boundary. + +def _viewer_facility_scope(user): + """Facility ids this user is confined to, or None when unrestricted. + + Returns a LIST (possibly empty) for the two scoped role groups and None for + everyone else. Empty list and None mean opposite things — [] is "no access + to anything", None is "no restriction" — so callers must test `is None` + rather than truthiness (CLAUDE.md rule 57's failure mode). + """ + if user.is_inspector: # rule 87 — never role == 'inspector' + return get_inspector_scope(user) or [] + if user.role == 'customer': # rule 89 — capability check, exact match + return get_customer_scope(user) or [] + return None + + +def _issue_in_scope(issue, scope_ids): + """Whether one issue falls inside an already-resolved facility scope. + + Takes the scope rather than the user so a caller filtering a list of issues + resolves it once instead of re-querying the assignment tables per row. + """ + if scope_ids is None: + return True + facility = issue.resolved_facility + return facility is not None and facility.id in scope_ids + + +def _issue_readable_by(issue, user): + """Single-issue convenience wrapper around the two helpers above.""" + return _issue_in_scope(issue, _viewer_facility_scope(user)) + + def _assignee_label(user): """Dropdown label for an assignee. @@ -414,18 +452,14 @@ def view(issue_id): if issue is None: abort(404) - if current_user.is_inspector: - fids = get_inspector_scope(current_user) - facility = issue.resolved_facility - if not fids or not facility or facility.id not in fids: - flash('Access denied.', 'danger') - return redirect(url_for('issues.index')) + # Scope gate — see _issue_readable_by(). This was two inline blocks that the + # linked-issues panel would have had to reproduce a third time; it is now + # one definition so the panel cannot end up more permissive than the page. + if not _issue_readable_by(issue, current_user): + flash('Access denied.', 'danger') + return redirect(url_for('issues.index')) + if current_user.role == 'customer': - cids = get_customer_scope(current_user) or [] - facility = issue.resolved_facility - if not facility or facility.id not in cids: - flash('Access denied.', 'danger') - return redirect(url_for('issues.index')) if request.method == 'POST': # Customers may only add a comment, and only on issues they follow or reported can_comment = (issue.is_followed_by(current_user) or issue.reported_by == current_user.id) @@ -710,7 +744,209 @@ def view(issue_id): form=form, comments=comments, comments_open=comments_open, - is_following=is_following) + is_following=is_following, + # Already filtered to links whose far end this viewer + # may open — see _readable_links(). + issue_links=_readable_links(issue, current_user), + link_types=IssueLink.TYPE_CHOICES, + can_manage_links=_can_manage_links(issue, current_user)) + + +# ── Issue links ─────────────────────────────────────────────────────────────── +# Connect a duplicate to its original, or two issues about the same thing, so +# whoever picks one up can reach the other. Links are purely navigational: they +# never touch status, SLA, assignee or followers on either issue. + +def _can_manage_links(issue, user): + """Who may add or remove a link on this issue. + + Deliberately the SAME set as the page's `can_edit` (the Update Issue panel): + admin / director / auditor, or the person the issue is assigned to. Keeping + the two identical means the panel's buttons and this gate cannot disagree — + the alternative is a second, slightly different rule that nobody remembers. + Widening it (to project_manager, or to the reporter) is a one-line change + here, but change `can_edit` in issues/view.html at the same time. + """ + return (user.role in ('admin', 'director', 'auditor') + or issue.assigned_to == user.id) + + +def _readable_links(issue, user): + """Links on this issue whose FAR END the viewer may also open. + + A link is a pointer to another issue's id, description and facility, so an + unfiltered panel would let a customer read an issue at a facility they have + no assignment to simply because one of our staff linked it. The scope is + resolved once for the whole list rather than per row. + + Returns a list of (link, other_issue, label) ready for the template. + """ + scope = _viewer_facility_scope(user) + visible = [] + for link in issue.all_links(): + other = link.other_issue(issue.id) + if other is None or not _issue_in_scope(other, scope): + continue + visible.append((link, other, link.label_for(issue.id))) + return visible + + +@bp.route('//links', methods=['POST']) +@login_required +def add_link(issue_id): + """Link this issue to another one.""" + issue = db.session.get(Issue, issue_id) + if issue is None: + abort(404) + if not _issue_readable_by(issue, current_user): + abort(403) + if not _can_manage_links(issue, current_user): + abort(403) + + link_type = request.form.get('link_type', '') + if link_type not in (IssueLink.TYPE_DUPLICATE, IssueLink.TYPE_RELATED): + flash('Choose how the two issues are related.', 'warning') + return redirect(_view_url(issue_id)) + + raw_target = (request.form.get('linked_issue_id') or '').strip().lstrip('#') + if not raw_target.isdigit(): + flash('Enter the number of the issue to link, e.g. 412.', 'warning') + return redirect(_view_url(issue_id)) + target_id = int(raw_target) + + if target_id == issue.id: + flash('An issue cannot be linked to itself.', 'warning') + return redirect(_view_url(issue_id)) + + target = db.session.get(Issue, target_id) + # A 404 and a 403 are the same message here on purpose: whether an issue + # outside your scope EXISTS is not something the link box should confirm. + if target is None or not _issue_readable_by(target, current_user): + flash(f'Issue #{target_id} was not found.', 'warning') + return redirect(_view_url(issue_id)) + + if IssueLink.exists_between(issue.id, target.id): + flash(f'Issue #{issue.id} and #{target.id} are already linked.', 'info') + return redirect(_view_url(issue_id)) + + link = IssueLink( + issue_id = issue.id, + linked_issue_id = target.id, + link_type = link_type, + created_by = current_user.id, + ) + db.session.add(link) + db.session.commit() + + log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}', + f'linked to #{target.id} as {link_type}') + current_app.logger.info( + 'ISSUE LINK | issue_id=%s | linked_issue_id=%s | type=%s | user=%s', + issue.id, target.id, link_type, current_user.username, + ) + flash(f'Issue #{issue.id} is now linked to #{target.id}.', 'success') + return redirect(_view_url(issue_id)) + + +@bp.route('//links//delete', methods=['POST']) +@login_required +def remove_link(issue_id, link_id): + """Remove a link. Either end of it may do this.""" + issue = db.session.get(Issue, issue_id) + if issue is None: + abort(404) + if not _issue_readable_by(issue, current_user): + abort(403) + if not _can_manage_links(issue, current_user): + abort(403) + + link = db.session.get(IssueLink, link_id) + # The link must actually touch THIS issue. Without the check, anyone able to + # manage links on any one issue could delete a link between two others by + # posting its id here. + if link is None or issue.id not in (link.issue_id, link.linked_issue_id): + flash('That link no longer exists.', 'info') + return redirect(_view_url(issue_id)) + + other_id = link.linked_issue_id if link.issue_id == issue.id else link.issue_id + db.session.delete(link) + db.session.commit() + + log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}', + f'unlinked from #{other_id}') + current_app.logger.info( + 'ISSUE UNLINK | issue_id=%s | linked_issue_id=%s | user=%s', + issue.id, other_id, current_user.username, + ) + flash(f'Removed the link to issue #{other_id}.', 'info') + return redirect(_view_url(issue_id)) + + +@bp.route('//link-search') +@login_required +def link_search(issue_id): + """JSON candidates for the link picker. + + Scoped exactly like the issue list, so an inspector or customer can only + find issues they could already open — searching must not become a way to + enumerate another contract's issues. The results are a convenience; the POST + in add_link() re-checks access and is the real boundary. + """ + issue = db.session.get(Issue, issue_id) + if issue is None: + abort(404) + if not _issue_readable_by(issue, current_user): + abort(403) + + term = (request.args.get('q') or '').strip().lstrip('#') + if len(term) < 1: + return jsonify({'results': []}) + + q = ( + Issue.query + .outerjoin(Area, Issue.area_id == Area.id) + .options(joinedload(Issue.facility), contains_eager(Issue.area)) + .filter(Issue.id != issue.id) + ) + + scope = _viewer_facility_scope(current_user) + if scope is not None: + if not scope: + return jsonify({'results': []}) + q = q.filter(db.or_( + Issue.facility_id.in_(scope), + db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(scope)), + )) + + # Exclude issues already linked in either direction — offering them only + # produces an "already linked" flash. + linked_ids = {other.id for _l, other, _lbl in _readable_links(issue, current_user)} + if linked_ids: + q = q.filter(Issue.id.notin_(linked_ids)) + + if term.isdigit(): + # A number is almost always an issue number, so match the id first and + # fall back to the description for things like "Room 204". + q = q.filter(db.or_(Issue.id == int(term), + Issue.description.ilike(f'%{term}%'))) + else: + q = q.filter(Issue.description.ilike(f'%{term}%')) + + matches = q.order_by(Issue.reported_at.desc()).limit(10).all() + + return jsonify({'results': [ + { + 'id': i.id, + 'description': (i.description or '')[:110], + 'status': (i.status or '').replace('_', ' ').title(), + 'severity': (i.severity or '').title(), + 'location': (i.area.name if i.area + else i.resolved_facility.name if i.resolved_facility + else '—'), + 'reported_at': i.reported_at.strftime('%Y-%m-%d') if i.reported_at else '', + } + for i in matches + ]}) # ── Follow ──────────────────────────────────────────────────────────────────── diff --git a/app/templates/issues/view.html b/app/templates/issues/view.html index 9462253..9759053 100644 --- a/app/templates/issues/view.html +++ b/app/templates/issues/view.html @@ -232,6 +232,84 @@ + {# ── Linked issues ────────────────────────────────────────────────────── + Duplicates and related issues, so whoever picks this one up can reach the + others. `issue_links` arrives already filtered to links whose far end this + viewer may open (_readable_links) — do NOT add links from the model + directly here, or a customer sees an issue at a facility they have no + assignment to. Links are navigational only: nothing here changes status, + SLA, assignee or followers on either issue. #} +
+
+
+ Linked Issues + {{ issue_links|length }} +
+ {% if can_manage_links %} + + {% endif %} +
+ +
+ {% if issue_links %} +
+ {% for link, other, label in issue_links %} +
+ {{ label }} + +
+ #{{ other.id }} + + {{ other.area.name if other.area + else other.resolved_facility.name if other.resolved_facility else '—' }} + +
+ {{ other.description }} +
+
+ +
+ {{ other.severity|title }} + + {{ other.status|replace('_',' ')|title }} + + {% if can_manage_links %} +
+ + + +
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +

+ + No linked issues. + {% if can_manage_links %} + Use Link an Issue to point at a duplicate or a related issue. + {% endif %} +

+ {% endif %} +
+
+ {# ── Comments ───────────────────────────────────────────────────────── #}
@@ -578,6 +656,77 @@ {% endif %}
+{# ── Link an issue ──────────────────────────────────────────────────────────── + Search is scoped server-side to issues this viewer could already open, so the + picker can never be used to enumerate another contract's issues. The POST + re-checks access — the search is only a convenience. #} +{% if can_manage_links %} + +{% endif %} + {% if current_user.role in ['admin', 'director'] %}