Initial TicketTracker release

This commit is contained in:
michael committed 2026-09-09 09:42:48 +02:00
1 parent d1da86beab
commit c2063fe1ed
11 files changed
+1124 -175

No files matched your search

+313 -26
View File
@@ -175,11 +175,30 @@ type ProjectRow = {
manual_consumption_minutes: number;
teamspace_consumption_minutes?: number;
consumption_minutes?: number;
teamspace_entry_count?: number;
teamspace_entries?: ProjectTeamspaceEntryRow[];
closed_at: string | null;
created_at: string;
updated_at: string;
};
type ProjectTeamspaceEntryRow = {
id: string;
project_id?: string;
teamspace_project: string | null;
is_default: boolean;
hour_budget_minutes: number | null;
manual_consumption_minutes: number;
teamspace_consumption_minutes?: number;
consumption_minutes?: number;
display_order?: number;
};
type ProjectWithEntries = ProjectRow & {
teamspace_entries: ProjectTeamspaceEntryRow[];
teamspace_entry_count: number;
};
const zammadBaseUrlSettingKey = "zammad.base_url";
const zammadApiKeySettingKey = "zammad.api_key";
@@ -200,6 +219,36 @@ async function setAppSetting(key: string, value: string) {
);
}
async function fetchZammadUserIdForToken(apiToken: string) {
const savedBaseUrl = await getAppSetting(zammadBaseUrlSettingKey);
if (!savedBaseUrl) {
throw badRequest("Zammad URL muss zuerst im Adminbereich gespeichert werden");
}
const baseUrl = parseZammadBaseUrl(savedBaseUrl);
const response = await fetch(`${baseUrl}/api/v1/users/me`, {
headers: {
Authorization: `Token token=${apiToken}`,
Accept: "application/json"
}
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw Object.assign(new Error(`Zammad Token konnte nicht geprüft werden (${response.status})${body ? `: ${body.slice(0, 250)}` : ""}`), { status: 502 });
}
const payload = (await response.json()) as { id?: unknown };
const zammadUserId = Number(payload.id);
if (!Number.isInteger(zammadUserId) || zammadUserId < 1) {
throw Object.assign(new Error("Zammad Antwort enthält keine gültige User-ID"), { status: 502 });
}
return zammadUserId;
}
async function getOrganizationByZammadId(
client: { query: (text: string, params?: unknown[]) => Promise<{ rows: OrganizationRow[]; rowCount: number | null }> },
zammadId: number
@@ -315,6 +364,58 @@ function parseOptionalNonNegativeInteger(value: unknown, field: string) {
return number;
}
function parseProjectTeamspaceEntries(
value: unknown,
fallback: { teamspaceProject: string | null; hourBudgetMinutes: number | null; manualConsumptionMinutes: number }
) {
const source = Array.isArray(value) && value.length > 0
? value
: [
{
teamspaceProject: fallback.teamspaceProject,
isDefault: true,
hourBudgetMinutes: fallback.hourBudgetMinutes,
manualConsumptionMinutes: fallback.manualConsumptionMinutes
}
];
if (source.length > 20) {
throw badRequest("teamspaceEntries must contain at most 20 entries");
}
let hasDefault = false;
const entries = source.map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
throw badRequest("teamspaceEntries must be an array of objects");
}
const entry = item as Record<string, unknown>;
const isDefault = entry.isDefault === undefined ? index === 0 : optionalBoolean(entry.isDefault, index === 0);
if (isDefault) {
if (hasDefault) {
throw badRequest("Only one teamspace entry can be default");
}
hasDefault = true;
}
return {
teamspaceProject: optionalString(entry.teamspaceProject),
isDefault,
hourBudgetMinutes: parseOptionalNonNegativeInteger(entry.hourBudgetMinutes, "teamspaceEntries.hourBudgetMinutes"),
manualConsumptionMinutes: parseOptionalNonNegativeInteger(entry.manualConsumptionMinutes, "teamspaceEntries.manualConsumptionMinutes") ?? 0,
displayOrder: index * 1024
};
});
if (!hasDefault) {
entries[0] = { ...entries[0], isDefault: true };
}
return entries;
}
function parseTodoPriority(value: unknown) {
if (value !== "low" && value !== "medium" && value !== "high" && value !== "urgent") {
throw badRequest("priority must be low, medium, high or urgent");
@@ -709,20 +810,37 @@ async function cleanupTicketDayBillingIfEmpty(
}
async function ensureProjectForTicket(
client: { query: (text: string, params?: unknown[]) => Promise<unknown> },
client: { query: <T = any>(text: string, params?: unknown[]) => Promise<{ rows: T[] }> },
ticketId: string,
userId: string
) {
await client.query(
const result = await client.query<{ id: string; teamspace_project: string | null; hour_budget_minutes: number | null; manual_consumption_minutes: number }>(
`
INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals)
SELECT $2, t.id, COALESCE(t.title, ''), '', ''
FROM tickets t
WHERE t.id = $1
ON CONFLICT (user_id, ticket_id) DO NOTHING;
ON CONFLICT (user_id, ticket_id) DO NOTHING
RETURNING id::text, teamspace_project, hour_budget_minutes, manual_consumption_minutes;
`,
[ticketId, userId]
);
if ("rows" in result && result.rows[0]) {
await client.query(
`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
VALUES ($1, $2, true, $3, $4, 0)
ON CONFLICT DO NOTHING;
`,
[
result.rows[0].id,
result.rows[0].teamspace_project,
result.rows[0].hour_budget_minutes,
result.rows[0].manual_consumption_minutes ?? 0
]
);
}
}
async function deleteProjectDataForTicket(
@@ -1600,8 +1718,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
p.goals,
p.starts_on::text AS starts_on,
p.ends_on::text AS ends_on,
p.hour_budget_minutes,
p.manual_consumption_minutes,
entry_totals.total_hour_budget_minutes AS hour_budget_minutes,
entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes,
EXISTS (
SELECT 1
FROM recurring_billings rb
@@ -1609,7 +1727,9 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number
) AS has_recurring_billing,
COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + p.manual_consumption_minutes)::int AS consumption_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes,
entry_totals.teamspace_entry_count,
entry_totals.teamspace_entries,
p.closed_at,
p.created_at,
p.updated_at
@@ -1622,6 +1742,31 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
WHERE tdb.ticket_id = p.ticket_id
AND tdb.user_id = p.user_id
) teamspace_totals ON true
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes,
COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes,
COUNT(*)::int AS teamspace_entry_count,
COALESCE(
json_agg(
json_build_object(
'id', pte.id::text,
'project_id', pte.project_id::text,
'teamspace_project', pte.teamspace_project,
'is_default', pte.is_default,
'hour_budget_minutes', pte.hour_budget_minutes,
'manual_consumption_minutes', pte.manual_consumption_minutes,
'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END,
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
'display_order', pte.display_order
)
ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC
),
'[]'::json
) AS teamspace_entries
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
) entry_totals ON true
WHERE p.ticket_id = $1
AND p.user_id = $2;
`,
@@ -1720,11 +1865,13 @@ app.post("/api/setup/admin", async (req, res) => {
display_name: string;
role: "admin" | "user";
active: boolean;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
}>(
`
INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, 'admin', true)
RETURNING id, username, display_name, role, active;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token;
`,
[username, displayName, await hashPassword(password)]
);
@@ -1773,9 +1920,19 @@ app.post("/api/auth/login", async (req, res) => {
role: "admin" | "user";
active: boolean;
password_hash: string;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
}>(
`
SELECT id, username, display_name, role, active, password_hash
SELECT
id,
username,
display_name,
role,
active,
password_hash,
zammad_user_id::text AS zammad_user_id,
(zammad_api_token IS NOT NULL) AS has_zammad_api_token
FROM users
WHERE lower(username) = lower($1);
`,
@@ -1796,7 +1953,9 @@ app.post("/api/auth/login", async (req, res) => {
username: user.username,
display_name: user.display_name,
role: user.role,
active: user.active
active: user.active,
zammad_user_id: user.zammad_user_id,
has_zammad_api_token: user.has_zammad_api_token
}
});
});
@@ -1867,6 +2026,9 @@ app.patch("/api/auth/me", async (req, res) => {
const displayName = requireString(req.body.displayName, "displayName");
const currentPassword = optionalString(req.body.currentPassword);
const newPassword = optionalString(req.body.newPassword);
const updatesZammadToken = Object.prototype.hasOwnProperty.call(req.body, "zammadApiToken") && user.role === "user";
const zammadApiToken = updatesZammadToken ? optionalString(req.body.zammadApiToken) : null;
const zammadUserId = zammadApiToken ? await fetchZammadUserIdForToken(zammadApiToken) : null;
if (newPassword && newPassword.length < 6) {
throw badRequest("newPassword must be at least 6 characters");
@@ -1892,11 +2054,13 @@ app.patch("/api/auth/me", async (req, res) => {
SET username = $1,
display_name = $2,
password_hash = COALESCE($3, password_hash),
zammad_api_token = CASE WHEN $5::boolean THEN $6 ELSE zammad_api_token END,
zammad_user_id = CASE WHEN $5::boolean THEN $7 ELSE zammad_user_id END,
updated_at = now()
WHERE id = $4
RETURNING id, username, display_name, role, active;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token;
`,
[username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id]
[username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id, updatesZammadToken, zammadApiToken, zammadUserId]
);
if (result.rowCount === 0) {
@@ -1923,7 +2087,7 @@ app.patch("/api/auth/me", async (req, res) => {
app.get("/api/admin/users", requireAdmin, async (_req, res) => {
const result = await query(
`
SELECT id, username, display_name, role, active, created_at, updated_at
SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, created_at, updated_at
FROM users
ORDER BY username ASC;
`
@@ -1947,7 +2111,7 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => {
`
INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, display_name, role, active, created_at, updated_at;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, created_at, updated_at;
`,
[username, displayName, await hashPassword(password), "user", active]
);
@@ -2008,7 +2172,7 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
password_hash = COALESCE($4, password_hash),
updated_at = now()
WHERE id = $5
RETURNING id, username, display_name, role, active, created_at, updated_at;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, created_at, updated_at;
`,
[username, displayName, active, password ? await hashPassword(password) : null, userId]
);
@@ -2248,6 +2412,7 @@ const databaseExportTables = [
"organizations",
"tickets",
"projects",
"project_teamspace_entries",
"ticket_todos",
"recurring_billings",
"recurring_billing_slots",
@@ -3395,8 +3560,8 @@ app.get("/api/projects", requireUser, async (req, res) => {
p.goals,
p.starts_on::text AS starts_on,
p.ends_on::text AS ends_on,
p.hour_budget_minutes,
p.manual_consumption_minutes,
entry_totals.total_hour_budget_minutes AS hour_budget_minutes,
entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes,
p.closed_at,
p.created_at,
p.updated_at,
@@ -3410,7 +3575,9 @@ app.get("/api/projects", requireUser, async (req, res) => {
AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number
) AS has_recurring_billing,
COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + p.manual_consumption_minutes)::int AS consumption_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes,
entry_totals.teamspace_entry_count,
entry_totals.teamspace_entries,
COALESCE(todo_totals.open_todos, 0)::int AS open_todos,
COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos,
COALESCE(todo_totals.done_todos, 0)::int AS done_todos
@@ -3432,6 +3599,31 @@ app.get("/api/projects", requireUser, async (req, res) => {
WHERE tdb.ticket_id = p.ticket_id
AND tdb.user_id = p.user_id
) teamspace_totals ON true
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes,
COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes,
COUNT(*)::int AS teamspace_entry_count,
COALESCE(
json_agg(
json_build_object(
'id', pte.id::text,
'project_id', pte.project_id::text,
'teamspace_project', pte.teamspace_project,
'is_default', pte.is_default,
'hour_budget_minutes', pte.hour_budget_minutes,
'manual_consumption_minutes', pte.manual_consumption_minutes,
'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END,
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
'display_order', pte.display_order
)
ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC
),
'[]'::json
) AS teamspace_entries
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
) entry_totals ON true
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos,
@@ -3473,8 +3665,8 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
p.goals,
p.starts_on::text AS starts_on,
p.ends_on::text AS ends_on,
p.hour_budget_minutes,
p.manual_consumption_minutes,
entry_totals.total_hour_budget_minutes AS hour_budget_minutes,
entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes,
EXISTS (
SELECT 1
FROM recurring_billings rb
@@ -3482,7 +3674,9 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number
) AS has_recurring_billing,
COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + p.manual_consumption_minutes)::int AS consumption_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes,
entry_totals.teamspace_entry_count,
entry_totals.teamspace_entries,
p.closed_at,
p.created_at,
p.updated_at
@@ -3495,6 +3689,31 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
WHERE tdb.ticket_id = p.ticket_id
AND tdb.user_id = p.user_id
) teamspace_totals ON true
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes,
COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes,
COUNT(*)::int AS teamspace_entry_count,
COALESCE(
json_agg(
json_build_object(
'id', pte.id::text,
'project_id', pte.project_id::text,
'teamspace_project', pte.teamspace_project,
'is_default', pte.is_default,
'hour_budget_minutes', pte.hour_budget_minutes,
'manual_consumption_minutes', pte.manual_consumption_minutes,
'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END,
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
'display_order', pte.display_order
)
ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC
),
'[]'::json
) AS teamspace_entries
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
) entry_totals ON true
WHERE p.id = $1
AND p.user_id = $2;
`,
@@ -3521,6 +3740,8 @@ app.post("/api/projects", requireUser, async (req, res) => {
const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn");
const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes");
const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0;
const teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes });
const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
if (startsOn && endsOn && endsOn < startsOn) {
throw badRequest("endsOn must be after startsOn");
@@ -3624,13 +3845,13 @@ app.post("/api/projects", requireUser, async (req, res) => {
[
userId,
ticketId,
teamspaceProject,
defaultTeamspaceEntry.teamspaceProject,
description,
goals,
startsOn,
endsOn,
hourBudgetMinutes,
manualConsumptionMinutes,
defaultTeamspaceEntry.hourBudgetMinutes,
defaultTeamspaceEntry.manualConsumptionMinutes,
ticketNumber,
organization.id,
organization.name,
@@ -3639,7 +3860,31 @@ app.post("/api/projects", requireUser, async (req, res) => {
]
);
return projectResult.rows[0];
const project = projectResult.rows[0];
for (const entry of teamspaceEntries) {
await client.query(
`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
VALUES ($1, $2, $3, $4, $5, $6);
`,
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder]
);
}
project.teamspace_entries = teamspaceEntries.map((entry, index) => ({
id: `new-${index}`,
teamspace_project: entry.teamspaceProject,
is_default: entry.isDefault,
hour_budget_minutes: entry.hourBudgetMinutes,
manual_consumption_minutes: entry.manualConsumptionMinutes,
teamspace_consumption_minutes: entry.isDefault ? 0 : 0,
consumption_minutes: entry.manualConsumptionMinutes,
display_order: entry.displayOrder
}));
project.teamspace_entry_count = teamspaceEntries.length;
return project;
});
if (created === "organization-required") {
@@ -3676,6 +3921,8 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn");
const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes");
const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0;
const teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes });
const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
if (startsOn && endsOn && endsOn < startsOn) {
throw badRequest("endsOn must be after startsOn");
@@ -3774,10 +4021,50 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
created_at,
updated_at;
`,
[teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, manualConsumptionMinutes, projectId, userId, ticketNumber, organization.id, organization.name, title]
[
defaultTeamspaceEntry.teamspaceProject,
description,
goals,
startsOn,
endsOn,
defaultTeamspaceEntry.hourBudgetMinutes,
defaultTeamspaceEntry.manualConsumptionMinutes,
projectId,
userId,
ticketNumber,
organization.id,
organization.name,
title
]
);
return result.rows[0];
const project = result.rows[0];
await client.query("DELETE FROM project_teamspace_entries WHERE project_id = $1;", [project.id]);
for (const entry of teamspaceEntries) {
await client.query(
`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
VALUES ($1, $2, $3, $4, $5, $6);
`,
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder]
);
}
project.teamspace_entries = teamspaceEntries.map((entry, index) => ({
id: `new-${index}`,
teamspace_project: entry.teamspaceProject,
is_default: entry.isDefault,
hour_budget_minutes: entry.hourBudgetMinutes,
manual_consumption_minutes: entry.manualConsumptionMinutes,
teamspace_consumption_minutes: entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0,
consumption_minutes: (entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0) + entry.manualConsumptionMinutes,
display_order: entry.displayOrder
}));
project.teamspace_entry_count = teamspaceEntries.length;
return project;
});
if (updated === "organization-not-found") {