feat: jerarquía, vínculos y suscriptores en el detalle (Fase 3)
This commit is contained in:
1 parent
bbb5fd2b1a
commit
9acaad4f62
7 files changed
+536
-1
No files matched your search
@@ -11,6 +11,7 @@ import { useReference, useReferenceLookups, useWorkflowStates } from "@/lib/stor
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
import { ActivityFeed } from "@/components/issues/ActivityFeed";
|
||||
import { IssueRelations } from "@/components/issues/IssueRelations";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { TypeIcon } from "@/components/ui/Icons";
|
||||
|
||||
@@ -74,7 +75,9 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
||||
...partial,
|
||||
...(user ? { actorId: user.id } : {}),
|
||||
});
|
||||
setIssue(updated);
|
||||
// Si cambió el padre, el objeto local puede quedar desfasado.
|
||||
const fresh = await api.getIssue(issue.id);
|
||||
setIssue(fresh ?? updated);
|
||||
// Refrescar la línea de tiempo con los eventos recién creados.
|
||||
void api.listActivity(issue.id).then(setActivity);
|
||||
} catch {
|
||||
@@ -123,6 +126,7 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
||||
const project = issue.projectId ? projectById.get(issue.projectId) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="detail-grid">
|
||||
{/* Columna principal */}
|
||||
<div>
|
||||
@@ -328,5 +332,20 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Jerarquía, vínculos y suscriptores (Fase 3) */}
|
||||
<div className="mt-4">
|
||||
<IssueRelations
|
||||
issue={issue}
|
||||
currentUserId={user?.id ?? null}
|
||||
users={new Map(users.map((u) => [u.id, u]))}
|
||||
onChanged={() => {
|
||||
void api.getIssue(issueId).then((fresh) => {
|
||||
if (fresh) setIssue(fresh);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import type { ID, Issue, Relation, User } from "@/lib/types";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
|
||||
interface IssueRelationsProps {
|
||||
issue: Issue;
|
||||
currentUserId: ID | null;
|
||||
users: Map<ID, User>;
|
||||
/** Refresca el issue (el padre/hijos pueden haber cambiado). */
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<string, { outgoing: string; incoming: string }> = {
|
||||
blocked_by: { outgoing: "Bloqueado por", incoming: "Bloquea a" },
|
||||
blocks: { outgoing: "Bloquea a", incoming: "Bloqueado por" },
|
||||
related: { outgoing: "Relacionado con", incoming: "Relacionado con" },
|
||||
duplicates: { outgoing: "Duplicado de", incoming: "Duplicado por" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Jerarquía, vínculos y suscriptores de un issue (Fase 3).
|
||||
*
|
||||
* Todo pasa por los endpoints dedicados de sam, que validan ciclos,
|
||||
* existencia y actores. Los errores del backend se muestran tal cual.
|
||||
*/
|
||||
export function IssueRelations({ issue, currentUserId, users, onChanged }: IssueRelationsProps) {
|
||||
const [children, setChildren] = useState<Issue[]>([]);
|
||||
const [parent, setParent] = useState<Issue | null>(null);
|
||||
const [relations, setRelations] = useState<Relation[]>([]);
|
||||
const [relatedIssues, setRelatedIssues] = useState<Map<ID, Issue>>(new Map());
|
||||
const [subscribers, setSubscribers] = useState<ID[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Formularios.
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [kind, setKind] = useState("blocked_by");
|
||||
const [newChildTitle, setNewChildTitle] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const [kids, rels, subs] = await Promise.all([
|
||||
api.listChildren(issue.id),
|
||||
api.listRelations(issue.id),
|
||||
api.listSubscribers(issue.id),
|
||||
]);
|
||||
setChildren(kids);
|
||||
setRelations(rels);
|
||||
setSubscribers(subs);
|
||||
|
||||
// Resolver los issues vinculados + el padre para mostrar identifiers.
|
||||
const ids = new Set<ID>();
|
||||
for (const r of rels) {
|
||||
ids.add(r.fromIssue === issue.id ? r.toIssue : r.fromIssue);
|
||||
}
|
||||
if (issue.parentId) ids.add(issue.parentId);
|
||||
|
||||
const resolved = new Map<ID, Issue>();
|
||||
await Promise.all(
|
||||
[...ids].map(async (id) => {
|
||||
const found = await api.getIssue(id);
|
||||
if (found) resolved.set(id, found);
|
||||
}),
|
||||
);
|
||||
setRelatedIssues(resolved);
|
||||
setParent(issue.parentId ? (resolved.get(issue.parentId) ?? null) : null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error cargando relaciones");
|
||||
}
|
||||
}, [issue.id, issue.parentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
/** Busca un issue por identifier exacto (ej. "PLAT-42"). */
|
||||
async function resolveIdentifier(raw: string): Promise<Issue> {
|
||||
const idf = raw.trim().toUpperCase();
|
||||
if (!idf) throw new Error("Escribe un identificador (ej. PLAT-42).");
|
||||
const found = await api.listIssues({ query: idf });
|
||||
const exact = found.find((i) => i.identifier.toUpperCase() === idf && i.id !== issue.id);
|
||||
if (!exact) throw new Error(`No existe el issue ${idf}.`);
|
||||
return exact;
|
||||
}
|
||||
|
||||
async function run(action: () => Promise<unknown>, after?: () => void) {
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
after?.();
|
||||
await refresh();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error");
|
||||
}
|
||||
}
|
||||
|
||||
const isSubscribed = currentUserId != null && subscribers.includes(currentUserId);
|
||||
|
||||
return (
|
||||
<div className="row g-3">
|
||||
{error && (
|
||||
<div className="col-12">
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jerarquía */}
|
||||
<div className="col-md-4">
|
||||
<div className="lp-card p-3 h-100">
|
||||
<div className="detail-section-title">Jerarquía</div>
|
||||
|
||||
{parent ? (
|
||||
<div className="mb-2" style={{ fontSize: 13 }}>
|
||||
<span style={{ color: "var(--inv-muted)" }}>Sub-issue de </span>
|
||||
<Link href={`/issues/${parent.id}`}>
|
||||
<span className="issue-identifier">{parent.identifier}</span>{" "}
|
||||
{parent.title}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-lp btn-sm ms-2"
|
||||
title="Volver a raíz"
|
||||
onClick={() => void run(() => api.setParent(issue.id, null, currentUserId ?? undefined))}
|
||||
>
|
||||
<i className="bx bx-x" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 13, color: "var(--inv-muted)" }}>Issue raíz.</p>
|
||||
)}
|
||||
|
||||
{children.length > 0 && (
|
||||
<ul className="list-unstyled mb-2">
|
||||
{children.map((child) => (
|
||||
<li key={child.id} className="py-1 d-flex align-items-center gap-2">
|
||||
<Link
|
||||
href={`/issues/${child.id}`}
|
||||
style={{ fontSize: 13 }}
|
||||
className="text-truncate"
|
||||
>
|
||||
<span className="issue-identifier">{child.identifier}</span>{" "}
|
||||
{child.title}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-lp btn-sm ms-auto"
|
||||
title="Sacar de la jerarquía"
|
||||
onClick={() => void run(() => api.setParent(child.id, null, currentUserId ?? undefined))}
|
||||
>
|
||||
<i className="bx bx-x" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="d-flex gap-1 mt-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const title = newChildTitle.trim();
|
||||
if (!title) return;
|
||||
setNewChildTitle("");
|
||||
void run(async () => {
|
||||
const created = await api.createIssue({
|
||||
title,
|
||||
projectId: issue.projectId,
|
||||
...(currentUserId ? { actorId: currentUserId } : {}),
|
||||
});
|
||||
await api.setParent(created.id, issue.id, currentUserId ?? undefined);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Nuevo sub-issue…"
|
||||
value={newChildTitle}
|
||||
onChange={(e) => setNewChildTitle(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-accent btn-sm" title="Crear sub-issue">
|
||||
<i className="bx bx-plus" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
className="d-flex gap-1 mt-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const idf = identifier;
|
||||
setIdentifier("");
|
||||
void run(async () => {
|
||||
const target = await resolveIdentifier(idf);
|
||||
await api.setParent(target.id, issue.id, currentUserId ?? undefined);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Añadir existente (PLAT-42)…"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-lp btn-sm" title="Mover como sub-issue">
|
||||
<i className="bx bx-plus" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vínculos */}
|
||||
<div className="col-md-4">
|
||||
<div className="lp-card p-3 h-100">
|
||||
<div className="detail-section-title">Vínculos</div>
|
||||
|
||||
{relations.length === 0 && (
|
||||
<p style={{ fontSize: 13, color: "var(--inv-muted)" }}>Sin vínculos.</p>
|
||||
)}
|
||||
|
||||
<ul className="list-unstyled mb-2">
|
||||
{relations.map((rel) => {
|
||||
const outgoing = rel.fromIssue === issue.id;
|
||||
const otherId = outgoing ? rel.toIssue : rel.fromIssue;
|
||||
const other = relatedIssues.get(otherId);
|
||||
const label = outgoing
|
||||
? (KIND_LABEL[rel.kind]?.outgoing ?? rel.kind)
|
||||
: (KIND_LABEL[rel.kind]?.incoming ?? rel.kind);
|
||||
|
||||
return (
|
||||
<li key={rel.id} className="py-1 d-flex align-items-center gap-2">
|
||||
<span style={{ fontSize: 12, color: "var(--inv-muted)" }}>{label}</span>
|
||||
{other ? (
|
||||
<Link href={`/issues/${other.id}`} style={{ fontSize: 13 }} className="text-truncate">
|
||||
<span className="issue-identifier">{other.identifier}</span>{" "}
|
||||
{other.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span style={{ fontSize: 13 }}>#{otherId}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-lp btn-sm ms-auto"
|
||||
title="Quitar vínculo"
|
||||
onClick={() =>
|
||||
void run(() => api.deleteRelation(rel.id, currentUserId ?? undefined))
|
||||
}
|
||||
>
|
||||
<i className="bx bx-x" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<RelationForm
|
||||
onAdd={(idf, relKind) =>
|
||||
void run(async () => {
|
||||
const target = await resolveIdentifier(idf);
|
||||
await api.addRelation(issue.id, target.id, relKind, currentUserId ?? undefined);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suscriptores */}
|
||||
<div className="col-md-4">
|
||||
<div className="lp-card p-3 h-100">
|
||||
<div className="detail-section-title">Suscriptores</div>
|
||||
|
||||
{subscribers.length === 0 && (
|
||||
<p style={{ fontSize: 13, color: "var(--inv-muted)" }}>
|
||||
Nadie sigue este issue todavía.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="d-flex flex-wrap gap-2 mb-2">
|
||||
{subscribers.map((id) => {
|
||||
const member = users.get(id);
|
||||
return (
|
||||
<span key={id} className="d-inline-flex align-items-center gap-1">
|
||||
<Avatar user={member} />
|
||||
<span style={{ fontSize: 12 }}>{member?.name ?? id}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{currentUserId && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${isSubscribed ? "btn-lp" : "btn-accent"}`}
|
||||
onClick={() =>
|
||||
void run(() =>
|
||||
isSubscribed
|
||||
? api.unsubscribe(issue.id, currentUserId, currentUserId)
|
||||
: api.subscribe(issue.id, currentUserId, currentUserId),
|
||||
)
|
||||
}
|
||||
>
|
||||
<i className={`bx ${isSubscribed ? "bx-bell-off" : "bx-bell"} me-1`} />
|
||||
{isSubscribed ? "Dejar de seguir" : "Seguir"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelationForm({ onAdd }: { onAdd: (identifier: string, kind: string) => void }) {
|
||||
const [idf, setIdf] = useState("");
|
||||
const [relKind, setRelKind] = useState("blocked_by");
|
||||
|
||||
return (
|
||||
<form
|
||||
className="d-flex gap-1 mt-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!idf.trim()) return;
|
||||
onAdd(idf, relKind);
|
||||
setIdf("");
|
||||
}}
|
||||
>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
style={{ maxWidth: 130 }}
|
||||
value={relKind}
|
||||
onChange={(e) => setRelKind(e.target.value)}
|
||||
title="Tipo de vínculo"
|
||||
>
|
||||
<option value="blocked_by">Bloqueado por</option>
|
||||
<option value="blocks">Bloquea a</option>
|
||||
<option value="related">Relacionado</option>
|
||||
<option value="duplicates">Duplica a</option>
|
||||
</select>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="PLAT-42…"
|
||||
value={idf}
|
||||
onChange={(e) => setIdf(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-lp btn-sm" title="Añadir vínculo">
|
||||
<i className="bx bx-plus" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
Activity,
|
||||
Comment,
|
||||
Relation,
|
||||
Cycle,
|
||||
ID,
|
||||
Issue,
|
||||
@@ -45,6 +46,20 @@ export interface ProyectosApi {
|
||||
// Línea de tiempo (Fase 2 en sam): eventos de creación, cambios y comentarios.
|
||||
listActivity(issueId: ID): Promise<Activity[]>;
|
||||
|
||||
// Relaciones (Fase 3 en sam)
|
||||
/** Hijos directos (sub-issues). */
|
||||
listChildren(issueId: ID): Promise<Issue[]>;
|
||||
/** Fija el padre (null = raíz). Valida ciclos en el backend. */
|
||||
setParent(issueId: ID, parentId: ID | null, actorId?: ID): Promise<Issue>;
|
||||
/** Vínculos donde el issue participa (cualquier dirección). */
|
||||
listRelations(issueId: ID): Promise<Relation[]>;
|
||||
addRelation(issueId: ID, toIssue: ID, kind: string, actorId?: ID): Promise<Relation>;
|
||||
deleteRelation(relationId: ID, actorId?: ID): Promise<void>;
|
||||
/** idUsuarios suscritos al issue. */
|
||||
listSubscribers(issueId: ID): Promise<ID[]>;
|
||||
subscribe(issueId: ID, userId: ID, actorId?: ID): Promise<void>;
|
||||
unsubscribe(issueId: ID, userId: ID, actorId?: ID): Promise<void>;
|
||||
|
||||
// Comentarios
|
||||
listComments(issueId: ID): Promise<Comment[]>;
|
||||
/** `authorId` es el idUsuario del usuario en sesión. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
Activity,
|
||||
Comment,
|
||||
Relation,
|
||||
Cycle,
|
||||
ID,
|
||||
Issue,
|
||||
@@ -161,6 +162,43 @@ export const httpApi: ProyectosApi = {
|
||||
|
||||
listActivity: (issueId: ID) => request<Activity[]>(`/api/proyectos/issues/${issueId}/activity`),
|
||||
|
||||
listChildren: (issueId: ID) => request<Issue[]>(`/api/proyectos/issues/${issueId}/children`),
|
||||
|
||||
setParent: (issueId: ID, parentId: ID | null, actorId?: ID) =>
|
||||
request<Issue>(`/api/proyectos/issues/${issueId}/parent`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(actorId ? { parentId, actorId } : { parentId }),
|
||||
}),
|
||||
|
||||
listRelations: (issueId: ID) => request<Relation[]>(`/api/proyectos/issues/${issueId}/relations`),
|
||||
|
||||
addRelation: (issueId: ID, toIssue: ID, kind: string, actorId?: ID) =>
|
||||
request<Relation>(`/api/proyectos/issues/${issueId}/relations`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ toIssue: Number(toIssue), kind, actorId }),
|
||||
}),
|
||||
|
||||
deleteRelation: (relationId: ID, actorId?: ID) => {
|
||||
const qs = actorId ? `?actorId=${encodeURIComponent(actorId)}` : "";
|
||||
return request<void>(`/api/proyectos/relations/${relationId}${qs}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
listSubscribers: (issueId: ID) => request<ID[]>(`/api/proyectos/issues/${issueId}/subscribers`),
|
||||
|
||||
subscribe: (issueId: ID, userId: ID, actorId?: ID) =>
|
||||
request<void>(`/api/proyectos/issues/${issueId}/subscribers`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId, actorId }),
|
||||
}),
|
||||
|
||||
unsubscribe: (issueId: ID, userId: ID, actorId?: ID) => {
|
||||
const params = new URLSearchParams({ userId });
|
||||
if (actorId) params.append("actorId", actorId);
|
||||
return request<void>(`/api/proyectos/issues/${issueId}/subscribers?${params}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
|
||||
listComments: (issueId: ID) => request<Comment[]>(`/api/proyectos/issues/${issueId}/comments`),
|
||||
|
||||
addComment: (issueId: ID, body: string, authorId: ID) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
Activity,
|
||||
Comment,
|
||||
Relation,
|
||||
Cycle,
|
||||
ID,
|
||||
Issue,
|
||||
@@ -27,6 +28,8 @@ const state = {
|
||||
issues: structuredClone(seed.issues),
|
||||
comments: structuredClone(seed.comments),
|
||||
activity: [] as Activity[],
|
||||
relations: [] as Relation[],
|
||||
subscriptions: [] as Array<{ issueId: ID; userId: ID }>,
|
||||
projects: structuredClone(seed.projects),
|
||||
cycles: structuredClone(seed.cycles),
|
||||
users: structuredClone(seed.users),
|
||||
@@ -212,6 +215,7 @@ export const mockApi: ProyectosApi = {
|
||||
title: input.title,
|
||||
description: input.description ?? "",
|
||||
status,
|
||||
parentId: null,
|
||||
priority: input.priority ?? 0,
|
||||
type: input.type ?? "task",
|
||||
assigneeId: input.assigneeId ?? null,
|
||||
@@ -250,6 +254,7 @@ export const mockApi: ProyectosApi = {
|
||||
if (patch.assigneeId !== undefined) track("assignee", issue.assigneeId, patch.assigneeId);
|
||||
if (patch.projectId !== undefined) track("project", issue.projectId, patch.projectId);
|
||||
if (patch.cycleId !== undefined) track("cycle", issue.cycleId, patch.cycleId);
|
||||
if (patch.parentId !== undefined) track("parent", issue.parentId ?? null, patch.parentId);
|
||||
if (patch.labelIds !== undefined)
|
||||
track("labels", issue.labelIds.join(","), patch.labelIds.join(","));
|
||||
|
||||
@@ -264,6 +269,87 @@ export const mockApi: ProyectosApi = {
|
||||
return issue;
|
||||
},
|
||||
|
||||
async listChildren(issueId: ID): Promise<Issue[]> {
|
||||
await delay(60);
|
||||
return state.issues.filter((i) => (i.parentId ?? null) === issueId);
|
||||
},
|
||||
|
||||
async setParent(issueId: ID, parentId: ID | null, actorId?: ID): Promise<Issue> {
|
||||
await delay(120);
|
||||
const issue = state.issues.find((i) => i.id === issueId);
|
||||
if (!issue) throw new Error(`Issue ${issueId} no encontrado`);
|
||||
if (parentId !== null) {
|
||||
if (parentId === issueId) throw new Error("Un issue no puede ser su propio padre.");
|
||||
if (!state.issues.some((i) => i.id === parentId)) throw new Error("Padre inexistente.");
|
||||
// Caminata de ancestros (réplica simplificada del backend).
|
||||
let ancestor: ID | null | undefined = parentId;
|
||||
while (ancestor != null) {
|
||||
if (ancestor === issueId) throw new Error("Asignar ese padre crearía un ciclo en la jerarquía.");
|
||||
ancestor = state.issues.find((i) => i.id === ancestor)?.parentId;
|
||||
}
|
||||
}
|
||||
const old = issue.parentId ?? null;
|
||||
issue.parentId = parentId;
|
||||
issue.updatedAt = new Date().toISOString();
|
||||
if (old !== parentId) logActivity(issueId, actorId ?? null, "parent", old, parentId);
|
||||
return issue;
|
||||
},
|
||||
|
||||
async listRelations(issueId: ID): Promise<Relation[]> {
|
||||
await delay(60);
|
||||
return state.relations.filter((r) => r.fromIssue === issueId || r.toIssue === issueId);
|
||||
},
|
||||
|
||||
async addRelation(issueId: ID, toIssue: ID, kind: string, actorId?: ID): Promise<Relation> {
|
||||
await delay(120);
|
||||
if (issueId === toIssue) throw new Error("Un issue no puede vincularse consigo mismo.");
|
||||
if (!state.issues.some((i) => i.id === issueId && i)) throw new Error("Issue inexistente.");
|
||||
if (!state.issues.some((i) => i.id === toIssue)) throw new Error("Issue destino inexistente.");
|
||||
const existing = state.relations.find(
|
||||
(r) => r.fromIssue === issueId && r.toIssue === toIssue && r.kind === kind,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const rel: Relation = {
|
||||
id: nextId("rel"),
|
||||
fromIssue: issueId,
|
||||
toIssue,
|
||||
kind,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
state.relations.push(rel);
|
||||
logActivity(issueId, actorId ?? null, "relation_added", null, `${kind}:${toIssue}`);
|
||||
return rel;
|
||||
},
|
||||
|
||||
async deleteRelation(relationId: ID, actorId?: ID): Promise<void> {
|
||||
await delay(100);
|
||||
const rel = state.relations.find((r) => r.id === relationId);
|
||||
if (!rel) return;
|
||||
state.relations = state.relations.filter((r) => r.id !== relationId);
|
||||
logActivity(rel.fromIssue, actorId ?? null, "relation_removed", `${rel.kind}:${rel.toIssue}`, null);
|
||||
},
|
||||
|
||||
async listSubscribers(issueId: ID): Promise<ID[]> {
|
||||
await delay(60);
|
||||
return state.subscriptions.filter((s) => s.issueId === issueId).map((s) => s.userId);
|
||||
},
|
||||
|
||||
async subscribe(issueId: ID, userId: ID, actorId?: ID): Promise<void> {
|
||||
await delay(100);
|
||||
if (!state.subscriptions.some((s) => s.issueId === issueId && s.userId === userId)) {
|
||||
state.subscriptions.push({ issueId, userId });
|
||||
logActivity(issueId, actorId ?? userId, "subscribed", null, userId);
|
||||
}
|
||||
},
|
||||
|
||||
async unsubscribe(issueId: ID, userId: ID, actorId?: ID): Promise<void> {
|
||||
await delay(100);
|
||||
state.subscriptions = state.subscriptions.filter(
|
||||
(s) => !(s.issueId === issueId && s.userId === userId),
|
||||
);
|
||||
logActivity(issueId, actorId ?? userId, "unsubscribed", userId, null);
|
||||
},
|
||||
|
||||
async listActivity(issueId: ID): Promise<Activity[]> {
|
||||
await delay(60);
|
||||
return state.activity
|
||||
|
||||
@@ -113,6 +113,7 @@ export const issues: Issue[] = [
|
||||
assigneeId: "u1",
|
||||
projectId: "p1",
|
||||
cycleId: "c3",
|
||||
parentId: null,
|
||||
labelIds: ["l2"],
|
||||
createdAt: "2026-10-01T09:15:00Z",
|
||||
updatedAt: "2026-10-12T15:40:00Z",
|
||||
@@ -129,6 +130,7 @@ export const issues: Issue[] = [
|
||||
assigneeId: "u2",
|
||||
projectId: "p1",
|
||||
cycleId: "c3",
|
||||
parentId: null,
|
||||
labelIds: ["l1", "l4"],
|
||||
createdAt: "2026-10-03T11:00:00Z",
|
||||
updatedAt: "2026-10-10T08:20:00Z",
|
||||
@@ -145,6 +147,7 @@ export const issues: Issue[] = [
|
||||
assigneeId: "u4",
|
||||
projectId: "p1",
|
||||
cycleId: "c3",
|
||||
parentId: null,
|
||||
labelIds: ["l3"],
|
||||
createdAt: "2026-09-28T14:30:00Z",
|
||||
updatedAt: "2026-10-11T17:05:00Z",
|
||||
@@ -161,6 +164,7 @@ export const issues: Issue[] = [
|
||||
assigneeId: "u3",
|
||||
projectId: "p1",
|
||||
cycleId: "c3",
|
||||
parentId: null,
|
||||
labelIds: ["l2"],
|
||||
createdAt: "2026-09-20T10:00:00Z",
|
||||
updatedAt: "2026-10-08T12:00:00Z",
|
||||
@@ -177,6 +181,7 @@ export const issues: Issue[] = [
|
||||
assigneeId: null,
|
||||
projectId: "p2",
|
||||
cycleId: null,
|
||||
parentId: null,
|
||||
labelIds: ["l1", "l3"],
|
||||
createdAt: "2026-10-06T09:00:00Z",
|
||||
updatedAt: "2026-10-06T09:00:00Z",
|
||||
@@ -193,6 +198,7 @@ export const issues: Issue[] = [
|
||||
assigneeId: "u1",
|
||||
projectId: "p2",
|
||||
cycleId: "c4",
|
||||
parentId: "i5",
|
||||
labelIds: ["l2"],
|
||||
createdAt: "2026-10-07T16:45:00Z",
|
||||
updatedAt: "2026-10-09T10:10:00Z",
|
||||
@@ -209,6 +215,7 @@ export const issues: Issue[] = [
|
||||
assigneeId: "u3",
|
||||
projectId: "p3",
|
||||
cycleId: "c2",
|
||||
parentId: null,
|
||||
labelIds: ["l5"],
|
||||
createdAt: "2026-08-15T13:20:00Z",
|
||||
updatedAt: "2026-09-30T09:00:00Z",
|
||||
@@ -225,6 +232,7 @@ export const issues: Issue[] = [
|
||||
assigneeId: "u2",
|
||||
projectId: "p1",
|
||||
cycleId: "c4",
|
||||
parentId: null,
|
||||
labelIds: ["l5"],
|
||||
createdAt: "2026-10-09T08:00:00Z",
|
||||
updatedAt: "2026-10-09T08:00:00Z",
|
||||
|
||||
@@ -118,6 +118,8 @@ export interface Issue {
|
||||
assigneeId: ID | null;
|
||||
projectId: ID | null;
|
||||
cycleId: ID | null;
|
||||
/** Id del issue padre (sub-issues, Fase 3 en sam). Null/ausente = raíz. */
|
||||
parentId: ID | null;
|
||||
labelIds: ID[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -149,6 +151,19 @@ export interface IssueInput {
|
||||
assigneeId?: ID | null;
|
||||
projectId?: ID | null;
|
||||
cycleId?: ID | null;
|
||||
parentId?: ID | null;
|
||||
labelIds?: ID[];
|
||||
actorId?: ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vínculo tipado entre issues (Fase 3 en sam).
|
||||
* kind: blocked_by, blocks, related, duplicates.
|
||||
*/
|
||||
export interface Relation {
|
||||
id: ID;
|
||||
fromIssue: ID;
|
||||
toIssue: ID;
|
||||
kind: string;
|
||||
createdAt: string;
|
||||
}
|
||||
Reference in new issue
Block a user