diff --git a/src/app/(app)/m/[initials]/i/[issueNumber]/page.tsx b/src/app/(app)/m/[initials]/i/[issueNumber]/page.tsx index 0e6e78f9..43c941a0 100644 --- a/src/app/(app)/m/[initials]/i/[issueNumber]/page.tsx +++ b/src/app/(app)/m/[initials]/i/[issueNumber]/page.tsx @@ -10,6 +10,8 @@ import { PageShell } from "~/components/layout/PageShell"; import { IssueTimeline } from "~/components/issues/IssueTimeline"; import { IssueSidebar } from "~/components/issues/IssueSidebar"; import { IssueBadgeGrid } from "~/components/issues/IssueBadgeGrid"; +import { OwnerBadge } from "~/components/issues/OwnerBadge"; +import { getMachineOwnerName } from "~/lib/issues/owner"; import { formatIssueId } from "~/lib/issues/utils"; import type { Issue, IssueWithAllRelations } from "~/lib/types"; @@ -67,6 +69,20 @@ export default async function IssueDetailPage({ name: true, initials: true, }, + with: { + owner: { + columns: { + id: true, + name: true, + }, + }, + invitedOwner: { + columns: { + id: true, + name: true, + }, + }, + }, }, reportedByUser: { columns: { @@ -141,6 +157,10 @@ export default async function IssueDetailPage({ redirect(`/m/${initials}`); } + // Cast issue to IssueWithAllRelations for type safety + const issueWithRelations = issue as unknown as IssueWithAllRelations; + const ownerName = getMachineOwnerName(issueWithRelations); + return ( {/* Back button */} @@ -154,12 +174,22 @@ export default async function IssueDetailPage({ {/* Header */}
- - {issue.machine.name} - +
+ + {issue.machine.name} + + {ownerName && ( +
+ + Game Owner: + {ownerName} + +
+ )} +

@@ -187,12 +217,12 @@ export default async function IssueDetailPage({

Activity

- + {/* Sticky Sidebar */} diff --git a/src/components/issues/IssueSidebar.tsx b/src/components/issues/IssueSidebar.tsx index d4065589..2f73c90b 100644 --- a/src/components/issues/IssueSidebar.tsx +++ b/src/components/issues/IssueSidebar.tsx @@ -3,6 +3,8 @@ import { Eye } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { SidebarActions } from "~/components/issues/SidebarActions"; import { WatchButton } from "~/components/issues/WatchButton"; +import { OwnerBadge } from "~/components/issues/OwnerBadge"; +import { isUserMachineOwner } from "~/lib/issues/owner"; import { type IssueWithAllRelations } from "~/lib/types"; import { resolveIssueReporter } from "~/lib/issues/utils"; @@ -54,9 +56,14 @@ export function IssueSidebar({ {reporter.initial}

- - {reporter.name} - +
+ + {reporter.name} + + {isUserMachineOwner(issue, reporter.id) && ( + + )} +
{reporter.email && ( {reporter.email} diff --git a/src/components/issues/IssueTimeline.tsx b/src/components/issues/IssueTimeline.tsx index d3f90f51..e499b50a 100644 --- a/src/components/issues/IssueTimeline.tsx +++ b/src/components/issues/IssueTimeline.tsx @@ -2,6 +2,8 @@ import React from "react"; import { formatDistanceToNow } from "date-fns"; import { Avatar, AvatarFallback } from "~/components/ui/avatar"; import { AddCommentForm } from "~/components/issues/AddCommentForm"; +import { OwnerBadge } from "~/components/issues/OwnerBadge"; +import { isUserMachineOwner } from "~/lib/issues/owner"; import { type IssueWithAllRelations } from "~/lib/types"; import { cn } from "~/lib/utils"; import { resolveIssueReporter } from "~/lib/issues/utils"; @@ -17,6 +19,7 @@ interface TimelineEvent { id: string; type: TimelineEventType; author: { + id?: string | null; name: string; avatarFallback: string; email?: string | null | undefined; @@ -29,9 +32,16 @@ interface TimelineEvent { // Components // ---------------------------------------------------------------------- -function TimelineItem({ event }: { event: TimelineEvent }): React.JSX.Element { +function TimelineItem({ + event, + issue, +}: { + event: TimelineEvent; + issue: IssueWithAllRelations; +}): React.JSX.Element { const isSystem = event.type === "system"; const isIssue = event.type === "issue"; + const isOwner = isUserMachineOwner(issue, event.author.id); return (
@@ -86,6 +96,7 @@ function TimelineItem({ event }: { event: TimelineEvent }): React.JSX.Element { > {event.author.name} + {isOwner && } {event.author.email && ( {"<"} @@ -141,6 +152,7 @@ export function IssueTimeline({ id: `issue-${issue.id}`, type: "issue", author: { + id: reporter.id ?? null, name: reporter.name, avatarFallback: reporter.initial, email: reporter.email, @@ -156,6 +168,7 @@ export function IssueTimeline({ id: c.id, type: c.isSystem ? "system" : "comment", author: { + id: c.author?.id ?? null, name: authorName, avatarFallback: authorName.slice(0, 2).toUpperCase(), email: c.author?.email, @@ -180,7 +193,7 @@ export function IssueTimeline({ {/* Events List */}
{allEvents.map((event) => ( - + ))} {/* Delightful Empty State when no comments yet */} diff --git a/src/components/issues/OwnerBadge.test.tsx b/src/components/issues/OwnerBadge.test.tsx new file mode 100644 index 00000000..7962ee01 --- /dev/null +++ b/src/components/issues/OwnerBadge.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { OwnerBadge } from "./OwnerBadge"; + +describe("OwnerBadge", () => { + it("renders the owner badge with crown icon", () => { + render(); + + const badge = screen.getByTestId("owner-badge"); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent("Game Owner"); + }); + + it("renders with default size", () => { + render(); + + const badge = screen.getByTestId("owner-badge"); + expect(badge).toHaveClass("gap-1"); + }); + + it("renders with small size", () => { + render(); + + const badge = screen.getByTestId("owner-badge"); + expect(badge).toHaveClass("text-[10px]"); + expect(badge).toHaveClass("px-1.5"); + }); + + it("applies custom className", () => { + render(); + + const badge = screen.getByTestId("owner-badge"); + expect(badge).toHaveClass("custom-class"); + }); +}); diff --git a/src/components/issues/OwnerBadge.tsx b/src/components/issues/OwnerBadge.tsx new file mode 100644 index 00000000..9e5bd278 --- /dev/null +++ b/src/components/issues/OwnerBadge.tsx @@ -0,0 +1,36 @@ +import type React from "react"; +import { Crown } from "lucide-react"; +import { Badge } from "~/components/ui/badge"; +import { cn } from "~/lib/utils"; + +interface OwnerBadgeProps { + className?: string; + size?: "sm" | "default"; +} + +/** + * OwnerBadge Component + * + * Displays a badge indicating that a user is the machine owner. + * Used in issue details to highlight the owner in the timeline, + * comments, and reporter field. + */ +export function OwnerBadge({ + className, + size = "default", +}: OwnerBadgeProps): React.JSX.Element { + return ( + + + Game Owner + + ); +} diff --git a/src/lib/issues/owner.test.ts b/src/lib/issues/owner.test.ts new file mode 100644 index 00000000..a59a62b4 --- /dev/null +++ b/src/lib/issues/owner.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { isUserMachineOwner, getMachineOwnerName } from "./owner"; +import type { IssueWithAllRelations } from "~/lib/types"; + +describe("isUserMachineOwner", () => { + const createMockIssue = ( + ownerId?: string | null, + invitedOwnerId?: string | null + ): IssueWithAllRelations => { + return { + id: "issue-1", + machineInitials: "MM", + issueNumber: 1, + title: "Test Issue", + description: null, + status: "new", + severity: "minor", + priority: "medium", + consistency: "intermittent", + reportedBy: null, + invitedReportedBy: null, + reporterName: null, + reporterEmail: null, + assignedTo: null, + closedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + machine: { + id: "machine-1", + name: "Medieval Madness", + owner: ownerId ? { id: ownerId, name: "Test Owner" } : null, + invitedOwner: invitedOwnerId + ? { id: invitedOwnerId, name: "Invited Owner" } + : null, + }, + comments: [], + watchers: [], + }; + }; + + it("returns true when user is the registered owner", () => { + const issue = createMockIssue("user-123"); + expect(isUserMachineOwner(issue, "user-123")).toBe(true); + }); + + it("returns true when user is the invited owner", () => { + const issue = createMockIssue(null, "invited-456"); + expect(isUserMachineOwner(issue, "invited-456")).toBe(true); + }); + + it("returns false when user is not the owner", () => { + const issue = createMockIssue("user-123"); + expect(isUserMachineOwner(issue, "user-999")).toBe(false); + }); + + it("returns false when userId is null", () => { + const issue = createMockIssue("user-123"); + expect(isUserMachineOwner(issue, null)).toBe(false); + }); + + it("returns false when userId is undefined", () => { + const issue = createMockIssue("user-123"); + expect(isUserMachineOwner(issue, undefined)).toBe(false); + }); + + it("returns false when there is no owner", () => { + const issue = createMockIssue(null, null); + expect(isUserMachineOwner(issue, "user-123")).toBe(false); + }); +}); + +describe("getMachineOwnerName", () => { + const createMockIssue = ( + ownerName?: string | null, + invitedOwnerName?: string | null + ): IssueWithAllRelations => { + return { + id: "issue-1", + machineInitials: "MM", + issueNumber: 1, + title: "Test Issue", + description: null, + status: "new", + severity: "minor", + priority: "medium", + consistency: "intermittent", + reportedBy: null, + invitedReportedBy: null, + reporterName: null, + reporterEmail: null, + assignedTo: null, + closedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + machine: { + id: "machine-1", + name: "Medieval Madness", + owner: ownerName ? { id: "owner-id", name: ownerName } : null, + invitedOwner: invitedOwnerName + ? { id: "invited-id", name: invitedOwnerName } + : null, + }, + comments: [], + watchers: [], + }; + }; + + it("returns registered owner name when present", () => { + const issue = createMockIssue("John Doe"); + expect(getMachineOwnerName(issue)).toBe("John Doe"); + }); + + it("returns invited owner name when registered owner is not present", () => { + const issue = createMockIssue(null, "Jane Smith"); + expect(getMachineOwnerName(issue)).toBe("Jane Smith"); + }); + + it("prefers registered owner over invited owner", () => { + const issue = createMockIssue("John Doe", "Jane Smith"); + expect(getMachineOwnerName(issue)).toBe("John Doe"); + }); + + it("returns null when there is no owner", () => { + const issue = createMockIssue(null, null); + expect(getMachineOwnerName(issue)).toBe(null); + }); +}); diff --git a/src/lib/issues/owner.ts b/src/lib/issues/owner.ts new file mode 100644 index 00000000..6bcf329e --- /dev/null +++ b/src/lib/issues/owner.ts @@ -0,0 +1,35 @@ +import type { IssueWithAllRelations } from "~/lib/types"; + +/** + * Checks if a given user ID is the machine owner + * + * @param issue - The issue with machine owner information + * @param userId - The user ID to check (can be null/undefined) + * @returns true if the user is the machine owner + */ +export function isUserMachineOwner( + issue: IssueWithAllRelations, + userId: string | null | undefined +): boolean { + if (!userId) return false; + + // Check if user is the registered owner + if (issue.machine.owner?.id === userId) return true; + + // Check if user is the invited owner + if (issue.machine.invitedOwner?.id === userId) return true; + + return false; +} + +/** + * Gets the machine owner's name + * + * @param issue - The issue with machine owner information + * @returns The owner's name or null if no owner + */ +export function getMachineOwnerName( + issue: IssueWithAllRelations +): string | null { + return issue.machine.owner?.name ?? issue.machine.invitedOwner?.name ?? null; +} diff --git a/src/lib/issues/utils.test.ts b/src/lib/issues/utils.test.ts index 539a7120..76a91a7a 100644 --- a/src/lib/issues/utils.test.ts +++ b/src/lib/issues/utils.test.ts @@ -4,9 +4,10 @@ import { resolveIssueReporter } from "./utils"; describe("resolveIssueReporter", () => { it("resolves reportedByUser", () => { const issue = { - reportedByUser: { name: "User", email: "user@example.com" }, + reportedByUser: { id: "user-1", name: "User", email: "user@example.com" }, }; expect(resolveIssueReporter(issue)).toEqual({ + id: "user-1", name: "User", email: "user@example.com", initial: "U", @@ -15,9 +16,14 @@ describe("resolveIssueReporter", () => { it("resolves invitedReporter if no reportedByUser", () => { const issue = { - invitedReporter: { name: "Invited", email: "invited@example.com" }, + invitedReporter: { + id: "invited-1", + name: "Invited", + email: "invited@example.com", + }, }; expect(resolveIssueReporter(issue)).toEqual({ + id: "invited-1", name: "Invited", email: "invited@example.com", initial: "I", @@ -30,6 +36,7 @@ describe("resolveIssueReporter", () => { reporterEmail: "legacy@example.com", }; expect(resolveIssueReporter(issue)).toEqual({ + id: null, name: "Legacy", email: "legacy@example.com", initial: "L", @@ -39,6 +46,7 @@ describe("resolveIssueReporter", () => { it("falls back to Anonymous", () => { const issue = {}; expect(resolveIssueReporter(issue)).toEqual({ + id: null, name: "Anonymous", email: null, initial: "A", diff --git a/src/lib/issues/utils.ts b/src/lib/issues/utils.ts index e53a8c5c..013f6e4c 100644 --- a/src/lib/issues/utils.ts +++ b/src/lib/issues/utils.ts @@ -3,13 +3,14 @@ export function formatIssueId(initials: string, number: number): string { } export interface IssueReporterInfo { - reportedByUser?: { name: string; email?: string | null } | null; - invitedReporter?: { name: string; email?: string | null } | null; + reportedByUser?: { id?: string; name: string; email?: string | null } | null; + invitedReporter?: { id?: string; name: string; email?: string | null } | null; reporterName?: string | null; reporterEmail?: string | null; } export function resolveIssueReporter(issue: IssueReporterInfo): { + id?: string | null; name: string; email?: string | null; initial: string; @@ -26,7 +27,10 @@ export function resolveIssueReporter(issue: IssueReporterInfo): { issue.reporterEmail ?? null; + const id = issue.reportedByUser?.id ?? issue.invitedReporter?.id ?? null; + return { + id, name, email, initial: (name[0] ?? "A").toUpperCase(), diff --git a/src/lib/types/issue.ts b/src/lib/types/issue.ts index 58e319d1..68a8d538 100644 --- a/src/lib/types/issue.ts +++ b/src/lib/types/issue.ts @@ -14,7 +14,13 @@ export type IssueListItem = Issue & { }; export type IssueWithAllRelations = Issue & { - machine: Pick; + machine: Pick & { + owner?: Pick | null; + invitedOwner?: { + id: string; + name: string; + } | null; + }; reportedByUser?: Pick | null; invitedReporter?: { id: string;