Initial TicketTracker release
This commit is contained in:
1 parent
d1da86beab
commit
c2063fe1ed
11 files changed
+1124
-175
No files matched your search
+13
-2
@@ -15,6 +15,8 @@ export type AuthUser = {
|
||||
display_name: string;
|
||||
role: UserRole;
|
||||
active: boolean;
|
||||
zammad_user_id: string | null;
|
||||
has_zammad_api_token: boolean;
|
||||
};
|
||||
|
||||
declare global {
|
||||
@@ -73,7 +75,9 @@ function publicUser(user: AuthUser) {
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
active: user.active
|
||||
active: user.active,
|
||||
zammad_user_id: user.zammad_user_id ?? null,
|
||||
has_zammad_api_token: Boolean(user.has_zammad_api_token)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,7 +127,14 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
|
||||
const tokenHash = hashToken(token);
|
||||
const result = await query<AuthUser>(
|
||||
`
|
||||
SELECT u.id, u.username, u.display_name, u.role, u.active
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.display_name,
|
||||
u.role,
|
||||
u.active,
|
||||
u.zammad_user_id::text AS zammad_user_id,
|
||||
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token
|
||||
FROM auth_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1
|
||||
|
||||
+313
-26
@@ -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") {
|
||||
|
||||
@@ -60,6 +60,8 @@ export async function migrate() {
|
||||
DROP CONSTRAINT IF EXISTS users_username_format,
|
||||
ADD CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s');
|
||||
`);
|
||||
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_api_token TEXT;");
|
||||
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_user_id BIGINT;");
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
@@ -485,6 +487,25 @@ export async function migrate() {
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS project_teamspace_entries (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
teamspace_project TEXT,
|
||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||
hour_budget_minutes INTEGER,
|
||||
manual_consumption_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT project_teamspace_entries_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
|
||||
CONSTRAINT project_teamspace_entries_manual_non_negative CHECK (manual_consumption_minutes >= 0)
|
||||
);
|
||||
`);
|
||||
|
||||
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);");
|
||||
|
||||
await query(`
|
||||
UPDATE tickets t
|
||||
SET work_type = 'consulting'
|
||||
@@ -506,6 +527,17 @@ export async function migrate() {
|
||||
ON CONFLICT (user_id, ticket_id) DO NOTHING;
|
||||
`);
|
||||
|
||||
await query(`
|
||||
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
|
||||
SELECT p.id, p.teamspace_project, true, p.hour_budget_minutes, p.manual_consumption_minutes, 0
|
||||
FROM projects p
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM project_teamspace_entries pte
|
||||
WHERE pte.project_id = p.id
|
||||
);
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS ticket_todos (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
Reference in new issue
Block a user