Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 61 additions & 10 deletions src/actions/expenses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,34 +99,47 @@ export async function editExpense(
throw new Error('Expense does not exist');
}

const [divisionTreasurer, localAdmin] = await Promise.all([
SessionService.isDivisionTreasurer(),
SessionService.isOrgLocalAdmin(existing.organizationId)
]);
const isAdmin = divisionTreasurer || localAdmin;

// Check if user has permission to edit this expense
// User can edit if:
// 1. They created the expense (for personal expenses)
// 2. They belong to the group that owns the expense (for group expenses)
// 1. They are an admin
// 2. They created the expense (for personal expenses)
// 3. They belong to the group that owns the expense (for group expenses)
const userGroups = await SessionService.getGroups();
const canEdit =
existing.gammaUserId === gammaUserId || // User created it
isAdmin ||
existing.gammaUserId === gammaUserId ||
(existing.gammaGroupId !== null &&
userGroups.some((g) => g.group.id === existing.gammaGroupId)); // User belongs to the group
userGroups.some((g) => g.group.id === existing.gammaGroupId));

if (!canEdit) {
throw new Error('User does not have permission to edit this expense');
}

if (existing.paidAt !== null || existing.status === RequestStatus.APPROVED) {
if (!isAdmin && (existing.paidAt !== null || existing.status === RequestStatus.APPROVED)) {
throw new Error(
'Expense cannot be edited after it has been paid or approved'
);
}

// Validate the new group if specified
// Admins keep the existing group when they don't belong to it
let group = null;
if (gammaGroupId !== null) {
group = userGroups.find((g) => g.group.id === gammaGroupId)?.group;
if (group === undefined) {
throw new Error(
'Group does not exist or user does not have access to it'
);
group = userGroups.find((g) => g.group.id === gammaGroupId)?.group ?? null;
if (group === null && !isAdmin) {
throw new Error('Group does not exist or user does not have access to it');
}
if (group === null && isAdmin) {
// Admin editing without group membership, preserve existing superGroup
group = existing.gammaGroupId === gammaGroupId
? { id: gammaGroupId, superGroup: { id: existing.gammaSuperGroupId! } } as any
: null;
}
}

Expand Down Expand Up @@ -223,13 +236,24 @@ export async function createPersonalExpense(
);
}

async function isOrgAdminForExpense(existing: NonNullable<Awaited<ReturnType<typeof ExpenseService.getById>>>) {
return (
await SessionService.isDivisionTreasurer() ||
await SessionService.isOrgLocalAdmin(existing.organizationId)
);
}

export async function markExpenseAsPaid(expenseId: number) {
const existing = await ExpenseService.getById(expenseId);

if (existing === null) {
throw new Error('Expense does not exist');
}

if (!(await isOrgAdminForExpense(existing))) {
throw new Error('User does not have admin permission for this expense');
}

return ExpenseService.markAsPaid(expenseId);
}

Expand All @@ -240,6 +264,10 @@ export async function markExpenseAsUnpaid(expenseId: number) {
throw new Error('Expense does not exist');
}

if (!(await isOrgAdminForExpense(existing))) {
throw new Error('User does not have admin permission for this expense');
}

return ExpenseService.markAsUnpaid(expenseId);
}

Expand All @@ -250,6 +278,21 @@ export async function deleteExpense(expenseId: number) {
throw new Error('Expense does not exist');
}

const gammaUserId = (await SessionService.getUser())?.id;

// Allow the creator to delete their own expense if it hasn't been paid or approved
if (gammaUserId && existing.gammaUserId === gammaUserId) {
if (existing.paidAt !== null || existing.status === RequestStatus.APPROVED) {
throw new Error('Expense cannot be deleted after it has been paid or approved');
}
return ExpenseService.delete(expenseId);
}

// Otherwise require org admin
if (!(await isOrgAdminForExpense(existing))) {
throw new Error('User does not have admin permission for this expense');
}

return ExpenseService.delete(expenseId);
}

Expand All @@ -260,6 +303,10 @@ export async function requestExpenseRevision(expenseId: number) {
throw new Error('Expense does not exist');
}

if (!(await isOrgAdminForExpense(existing))) {
throw new Error('User does not have admin permission for this expense');
}

return ExpenseService.requestRevision(expenseId);
}

Expand All @@ -270,6 +317,10 @@ export async function approveExpense(expenseId: number) {
throw new Error('Expense does not exist');
}

if (!(await isOrgAdminForExpense(existing))) {
throw new Error('User does not have admin permission for this expense');
}

return ExpenseService.approve(expenseId);
}

Expand Down
82 changes: 61 additions & 21 deletions src/actions/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,34 +84,42 @@ export async function editInvoice(
throw new Error('Invoice does not exist');
}

const [divisionTreasurer, localAdmin] = await Promise.all([
SessionService.isDivisionTreasurer(),
SessionService.isOrgLocalAdmin(existing.organizationId)
]);
const isAdmin = divisionTreasurer || localAdmin;

// Check if user has permission to edit this invoice
// User can edit if:
// 1. They created the invoice (for personal invoices)
// 2. They belong to the group that owns the invoice (for group invoices)
const userGroups = await SessionService.getGroups();
const canEdit =
existing.gammaUserId === gammaUserId || // User created it
isAdmin ||
existing.gammaUserId === gammaUserId ||
(existing.gammaGroupId !== null &&
userGroups.some((g) => g.group.id === existing.gammaGroupId)); // User belongs to the group
userGroups.some((g) => g.group.id === existing.gammaGroupId));

if (!canEdit) {
throw new Error('User does not have permission to edit this invoice');
}

if (existing.sentAt !== null || existing.status === RequestStatus.APPROVED) {
if (!isAdmin && (existing.sentAt !== null || existing.status === RequestStatus.APPROVED)) {
throw new Error(
'Invoice cannot be edited after it has been sent or approved'
);
}

// Validate the new group if specified
// Admins keep the existing group when they don't belong to it
let group = null;
if (gammaGroupId !== null) {
group = userGroups.find((g) => g.group.id === gammaGroupId)?.group;
if (group === undefined) {
throw new Error(
'Group does not exist or user does not have access to it'
);
group = userGroups.find((g) => g.group.id === gammaGroupId)?.group ?? null;
if (group === null && !isAdmin) {
throw new Error('Group does not exist or user does not have access to it');
}
if (group === null && isAdmin) {
group = existing.gammaGroupId === gammaGroupId
? { id: gammaGroupId, superGroup: { id: existing.gammaSuperGroupId! } } as any
: null;
}
}

Expand Down Expand Up @@ -185,22 +193,54 @@ export async function createPersonalInvoice(
);
}

export async function markInvoiceAsSent(expenseId: number) {
return InvoiceService.markAsSent(expenseId);
async function assertOrgAdminForInvoice(invoiceId: number) {
const existing = await InvoiceService.getById(invoiceId);
if (existing === null) throw new Error('Invoice does not exist');

const [divisionTreasurer, localAdmin] = await Promise.all([
SessionService.isDivisionTreasurer(),
SessionService.isOrgLocalAdmin(existing.organizationId)
]);
if (!divisionTreasurer && !localAdmin) {
throw new Error('User does not have admin permission for this invoice');
}
}

export async function markInvoiceAsSent(invoiceId: number) {
await assertOrgAdminForInvoice(invoiceId);
return InvoiceService.markAsSent(invoiceId);
}

export async function markInvoiceAsNotSent(expenseId: number) {
return InvoiceService.markAsNotSent(expenseId);
export async function markInvoiceAsNotSent(invoiceId: number) {
await assertOrgAdminForInvoice(invoiceId);
return InvoiceService.markAsNotSent(invoiceId);
}

export async function deleteInvoice(expenseId: number) {
return InvoiceService.delete(expenseId);
export async function deleteInvoice(invoiceId: number) {
const existing = await InvoiceService.getById(invoiceId);
if (existing === null) throw new Error('Invoice does not exist');

const gammaUserId = (await SessionService.getUser())?.id;

// Allow the creator to delete their own invoice if it hasn't been sent or approved
if (gammaUserId && existing.gammaUserId === gammaUserId) {
if (existing.sentAt !== null || existing.status === RequestStatus.APPROVED) {
throw new Error('Invoice cannot be deleted after it has been sent or approved');
}
return InvoiceService.delete(invoiceId);
}

// Otherwise require org admin
await assertOrgAdminForInvoice(invoiceId);
return InvoiceService.delete(invoiceId);
}

export async function requestInvoiceRevision(expenseId: number) {
return InvoiceService.requestRevision(expenseId);
export async function requestInvoiceRevision(invoiceId: number) {
await assertOrgAdminForInvoice(invoiceId);
return InvoiceService.requestRevision(invoiceId);
}

export async function approveInvoice(expenseId: number) {
return InvoiceService.approve(expenseId);
export async function approveInvoice(invoiceId: number) {
await assertOrgAdminForInvoice(invoiceId);
return InvoiceService.approve(invoiceId);
}
45 changes: 35 additions & 10 deletions src/actions/nameLists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,29 +86,36 @@ export async function editNameList(
throw new Error('Name list does not exist');
}

// Check if user has permission to edit this name list
// User can edit if:
// 1. They created the name list (for personal name lists)
// 2. They belong to the group that owns the name list (for group name lists)
const [divisionTreasurer, localAdmin] = await Promise.all([
SessionService.isDivisionTreasurer(),
SessionService.isOrgLocalAdmin(existing.organizationId)
]);
const isAdmin = divisionTreasurer || localAdmin;

const userGroups = await SessionService.getGroups();
const canEdit =
existing.gammaUserId === gammaUserId || // User created it
isAdmin ||
existing.gammaUserId === gammaUserId ||
(existing.gammaGroupId !== null &&
userGroups.some((g) => g.group.id === existing.gammaGroupId)); // User belongs to the group
userGroups.some((g) => g.group.id === existing.gammaGroupId));

if (!canEdit) {
throw new Error('User does not have permission to edit this name list');
}

console.log('Editing name list with ID:', id, 'and gammaGroupId:', gammaGroupId);

// Validate the new group if specified
// Admins keep the existing group when they don't belong to it
let group = null;
if (gammaGroupId !== null) {
group = userGroups.find((g) => g.group.id === gammaGroupId)?.group;
if (group === undefined) {
group = userGroups.find((g) => g.group.id === gammaGroupId)?.group ?? null;
if (group === null && !isAdmin) {
throw new Error('Group does not exist or user does not have access to it');
}
if (group === null && isAdmin) {
group = existing.gammaGroupId === gammaGroupId
? { id: gammaGroupId, superGroup: { id: existing.gammaSuperGroupId! } } as any
: null;
}
}

await NameListService.edit(
Expand All @@ -125,5 +132,23 @@ export async function editNameList(
}

export async function deleteNameList(id: number) {
const existing = await NameListService.getById(id);
if (existing === null) throw new Error('Name list does not exist');

const gammaUserId = (await SessionService.getUser())?.id;

// Allow creator to delete their own name list
if (gammaUserId && existing.gammaUserId === gammaUserId) {
return NameListService.delete(id);
}

const [divisionTreasurer, localAdmin] = await Promise.all([
SessionService.isDivisionTreasurer(),
SessionService.isOrgLocalAdmin(existing.organizationId)
]);
if (!divisionTreasurer && !localAdmin) {
throw new Error('User does not have admin permission for this name list');
}

await NameListService.delete(id);
}
44 changes: 35 additions & 9 deletions src/actions/zettleSales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,28 +50,36 @@ export async function editZettleSale(
throw new Error('Zettle sale does not exist');
}

// Check if user has permission to edit this Zettle sale
// User can edit if they belong to the group that owns the sale
const [divisionTreasurer, localAdmin] = await Promise.all([
SessionService.isDivisionTreasurer(),
SessionService.isOrgLocalAdmin(existing.organizationId)
]);
const isAdmin = divisionTreasurer || localAdmin;

const userGroups = await SessionService.getGroups();
const canEdit =
existing.gammaGroupId !== null &&
userGroups.some((g) => g.group.id === existing.gammaGroupId);
isAdmin ||
(existing.gammaGroupId !== null &&
userGroups.some((g) => g.group.id === existing.gammaGroupId));

if (!canEdit) {
throw new Error('User does not have permission to edit this Zettle sale');
}

// Validate the new group
// Resolve group.
// Admins may not be members, so fall back to existing superGroup
const group = userGroups.find((g) => g.group.id === gammaGroupId)?.group;
if (group === undefined) {
const gammaSuperGroupId =
group?.superGroup.id ??
(isAdmin ? existing.gammaSuperGroupId : undefined);

if (!gammaSuperGroupId) {
throw new Error('Group does not exist or user does not have access to it');
}

console.log('Editing Zettle sale with ID:', id, 'and gammaGroupId:', gammaGroupId);

await ZettleSaleService.edit(
id,
group.superGroup.id,
gammaSuperGroupId,
gammaGroupId,
name,
amount,
Expand All @@ -80,5 +88,23 @@ export async function editZettleSale(
}

export async function deleteZettleSale(id: number) {
const existing = await ZettleSaleService.getById(id);
if (existing === null) throw new Error('Zettle sale does not exist');

const gammaUserId = (await SessionService.getUser())?.id;

// Allow creator to delete their own sale
if (gammaUserId && existing.gammaUserId === gammaUserId) {
return ZettleSaleService.delete(id);
}

const [divisionTreasurer, localAdmin] = await Promise.all([
SessionService.isDivisionTreasurer(),
SessionService.isOrgLocalAdmin(existing.organizationId)
]);
if (!divisionTreasurer && !localAdmin) {
throw new Error('User does not have admin permission for this Zettle sale');
}

await ZettleSaleService.delete(id);
}
Loading