Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Typescript: Move more sequelize model attributes to declare #7558

Merged
merged 2 commits into from
Aug 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
51 changes: 31 additions & 20 deletions server/graphql/common/expenses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { EXPENSE_PERMISSION_ERROR_CODES } from '../../constants/permissions';
import POLICIES from '../../constants/policies';
import { TransactionKind } from '../../constants/transaction-kind';
import { getFxRate } from '../../lib/currency';
import { simulateDBEntriesDiff } from '../../lib/data';
import errors from '../../lib/errors';
import logger from '../../lib/logger';
import { floatAmountToCents } from '../../lib/math';
Expand Down Expand Up @@ -682,7 +683,7 @@ const computeTotalAmountForExpense = (items: Record<string, unknown>[], taxes: T
};

/** Check expense's items values, throw if something's wrong */
const checkExpenseItems = (expenseData, items, taxes) => {
const checkExpenseItems = (expenseType, items, taxes) => {
// Check the number of items
if (!items || items.length === 0) {
throw new ValidationFailed('Your expense needs to have at least one item');
Expand All @@ -691,13 +692,21 @@ const checkExpenseItems = (expenseData, items, taxes) => {
}

// Check amounts
items.forEach((item, idx) => {
if (isNil(item.amount)) {
throw new ValidationFailed(
`Amount not set for item ${item.description ? `"${item.description}"` : `number ${idx}`}`,
);
}
});

const sumItems = computeTotalAmountForExpense(items, taxes);
if (!sumItems) {
throw new ValidationFailed(`The sum of all items must be above 0`);
}

// If expense is a receipt (not an invoice) then files must be attached
if (expenseData.type === EXPENSE_TYPE.RECEIPT) {
if (expenseType === EXPENSE_TYPE.RECEIPT) {
const hasMissingFiles = items.some(a => !a.url);
if (hasMissingFiles) {
throw new ValidationFailed('Some items are missing a file');
Expand Down Expand Up @@ -848,7 +857,7 @@ export async function createExpense(
const taxes = expenseData.tax || [];

checkTaxes(collective, collective.host, expenseData.type, taxes);
checkExpenseItems(expenseData, itemsData, taxes);
checkExpenseItems(expenseData.type, itemsData, taxes);

if (size(expenseData.attachedFiles) > 15) {
throw new ValidationFailed('The number of files that you can attach to an expense is limited to 15');
Expand Down Expand Up @@ -983,15 +992,13 @@ export const changesRequireStatusUpdate = (
export const getItemsChanges = async (
existingItems: ExpenseItem[],
expenseData: ExpenseData,
): Promise<
[boolean, Record<string, unknown>[], [Record<string, unknown>[], ExpenseItem[], Record<string, unknown>[]]]
> => {
): Promise<[boolean, [Record<string, unknown>[], ExpenseItem[], Record<string, unknown>[]]]> => {
if (expenseData.items) {
const itemsDiff = models.ExpenseItem.diffDBEntries(existingItems, expenseData.items);
const hasItemChanges = flatten(<unknown[]>itemsDiff).length > 0;
return [hasItemChanges, expenseData.items, itemsDiff];
return [hasItemChanges, itemsDiff];
} else {
return [false, [], [[], [], []]];
return [false, [[], [], []]];
}
};

Expand Down Expand Up @@ -1049,7 +1056,7 @@ export async function editExpense(
],
});

const [hasItemChanges, itemsData, itemsDiff] = await getItemsChanges(expense.items, expenseData);
const [hasItemChanges, itemsDiff] = await getItemsChanges(expense.items, expenseData);
const taxes = expenseData.tax || expense.data?.taxes || [];
const expenseType = expenseData.type || expense.type;
checkTaxes(expense.collective, expense.collective.host, expenseType, taxes);
Expand Down Expand Up @@ -1130,14 +1137,14 @@ export async function editExpense(
logger.warn('The legal name should match the bank account holder name (${accountHolderName} ≠ ${legalName})');
}
}
const updatedExpense = await sequelize.transaction(async t => {
const updatedExpense = await sequelize.transaction(async transaction => {
// Update payout method if we get new data from one of the param for it
if (
!isPaidCreditCardCharge &&
expenseData.payoutMethod !== undefined &&
expenseData.payoutMethod?.id !== expense.PayoutMethodId
) {
payoutMethod = await getPayoutMethodFromExpenseData(expenseData, remoteUser, fromCollective, t);
payoutMethod = await getPayoutMethodFromExpenseData(expenseData, remoteUser, fromCollective, transaction);

// Reset fees payer when changing the payout method and the new one doesn't support it
if (feesPayer === ExpenseFeesPayer.PAYEE && !models.PayoutMethod.typeSupportsFeesPayer(payoutMethod?.type)) {
Expand All @@ -1147,22 +1154,26 @@ export async function editExpense(

// Update items
if (hasItemChanges) {
checkExpenseItems({ ...expense.dataValues, ...cleanExpenseData }, itemsData, taxes);
const [newItemsData, oldItems, itemsToUpdate] = itemsDiff;
const simulatedItemsUpdate = simulateDBEntriesDiff(expense.items, itemsDiff);
checkExpenseItems(expenseType, simulatedItemsUpdate, taxes);
const [newItemsData, itemsToRemove, itemsToUpdate] = itemsDiff;
await Promise.all(<Promise<void>[]>[
// Delete
...oldItems.map(item => {
return item.destroy({ transaction: t });
...itemsToRemove.map(item => {
return item.destroy({ transaction });
}),
// Create
...newItemsData.map(itemData => {
return models.ExpenseItem.createFromData(itemData, remoteUser, expense, t);
return models.ExpenseItem.createFromData(itemData, remoteUser, expense, transaction);
}),
// Update
...itemsToUpdate.map(itemData => {
return models.ExpenseItem.updateFromData(itemData, t);
return models.ExpenseItem.updateFromData(itemData, transaction);
}),
]);

// Reload items
expense.items = await expense.getItems({ transaction, order: [['id', 'ASC']] });
}

// Update expense
Expand All @@ -1182,7 +1193,7 @@ export async function editExpense(
expenseData.attachedFiles,
);

await createAttachedFiles(expense, newAttachedFiles, remoteUser, t);
await createAttachedFiles(expense, newAttachedFiles, remoteUser, transaction);
await Promise.all(removedAttachedFiles.map((file: ExpenseAttachedFile) => file.destroy()));
await Promise.all(
updatedAttachedFiles.map((file: Record<string, unknown>) =>
Expand All @@ -1201,7 +1212,7 @@ export async function editExpense(
const updatedExpenseProps = {
...cleanExpenseData,
data: !expense.data ? null : cloneDeep(expense.data),
amount: computeTotalAmountForExpense(expenseData.items || expense.items, taxes),
amount: computeTotalAmountForExpense(expense.items, taxes),
lastEditedById: remoteUser.id,
incurredAt: expenseData.incurredAt || new Date(),
status,
Expand All @@ -1217,7 +1228,7 @@ export async function editExpense(
if (!isEqual(expense.data?.taxes, taxes)) {
set(updatedExpenseProps, 'data.taxes', taxes);
}
return expense.update(updatedExpenseProps, { transaction: t });
return expense.update(updatedExpenseProps, { transaction });
});

if (isPaidCreditCardCharge) {
Expand Down
35 changes: 31 additions & 4 deletions server/lib/data.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { differenceBy } from 'lodash';
import { differenceBy, merge } from 'lodash';
import { Model } from 'sequelize';

/** A diff as [newEntries, removedEntries, updatedEntries] */
type DBEntriesDiff<T extends Model> = [Record<string, unknown>[], T[], Record<string, unknown>[]];

/**
* Diff two lists of DB objects and returns which ones where created, removed and updated.
* Useful for places where we update an attribute by providing a list.
Expand All @@ -10,16 +13,15 @@ import { Model } from 'sequelize';
* @param {Array} diffedFields: The fields used to compare objects for ``
* @returns [newEntries, removedEntries, updatedEntries]
*/
export function diffDBEntries<T extends Model<T>>(
export function diffDBEntries<T extends Model>(
oldEntries: T[],
newEntriesData: Record<string, unknown>[],
diffedFields: string[],
idField = 'id',
): [Record<string, unknown>[], T[], Record<string, unknown>[]] {
): DBEntriesDiff<T> {
const toRemove = differenceBy(oldEntries, newEntriesData, idField);
const toCreate = [];
const toUpdate = [];

newEntriesData.forEach(entry => {
if (!entry[idField]) {
toCreate.push(entry);
Expand All @@ -42,3 +44,28 @@ export function diffDBEntries<T extends Model<T>>(

return [toCreate, toRemove, toUpdate];
}

/**
* Returns a simulated list of DB entries based on existing ones modified after the diff
* generated by `diffDBEntries(oldEntries, newEntriesData)`.
*/
export function simulateDBEntriesDiff<T extends Model>(
oldEntries: T[],
diff: DBEntriesDiff<T>,
idField = 'id',
): Record<string, unknown>[] {
const [newEntries, removedEntries, updatedEntries] = diff;
return [
...oldEntries
.filter(entry => !removedEntries.some(removedEntry => removedEntry === entry))
.map(entry => {
const updatedEntry = updatedEntries.find(updatedEntry => updatedEntry[idField] === entry.get(idField));
if (updatedEntry) {
return merge(entry['dataValues'], updatedEntry);
} else {
return <Record<string, unknown>>entry;
}
}),
...newEntries,
];
}
23 changes: 0 additions & 23 deletions server/lib/restore-sequelize-attributes-on-class.ts

This file was deleted.

37 changes: 16 additions & 21 deletions server/models/ExpenseItem.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,40 @@
import { pick } from 'lodash';
import type { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize';
import { DataTypes, Model, Transaction } from 'sequelize';

import { diffDBEntries } from '../lib/data';
import { isValidUploadedImage } from '../lib/images';
import restoreSequelizeAttributesOnClass from '../lib/restore-sequelize-attributes-on-class';
import { buildSanitizerOptions, sanitizeHTML } from '../lib/sanitize-html';
import sequelize from '../lib/sequelize';

import models from '.';

// Expense items diff as [newEntries, removedEntries, updatedEntries]
type ExpenseItemsDiff = [Record<string, unknown>[], ExpenseItem[], Record<string, unknown>[]];

/**
* Sequelize model to represent an ExpenseItem, linked to the `ExpenseItems` table.
*/
export class ExpenseItem extends Model {
public readonly id!: number;
public ExpenseId!: number;
public CreatedByUserId!: number;
public amount!: number;
public url!: string;
public createdAt!: Date;
public updatedAt!: Date;
public deletedAt: Date;
public incurredAt!: Date;
public description: string;
export class ExpenseItem extends Model<InferAttributes<ExpenseItem>, InferCreationAttributes<ExpenseItem>> {
public declare readonly id: CreationOptional<number>;
public declare ExpenseId: number;
public declare CreatedByUserId: number;
public declare amount: number;
public declare url: string;
public declare createdAt: CreationOptional<Date>;
public declare updatedAt: CreationOptional<Date>;
public declare deletedAt: CreationOptional<Date>;
public declare incurredAt: Date;
public declare description: CreationOptional<string>;

private static editableFields = ['amount', 'url', 'description', 'incurredAt'];

constructor(...args) {
super(...args);
restoreSequelizeAttributesOnClass(new.target, this);
}

/**
* Based on `diffDBEntries`, diff two items list to know which ones where
* added, removed or added.
* @returns [newEntries, removedEntries, updatedEntries]
*/
static diffDBEntries = (
baseItems: ExpenseItem[],
itemsData: Record<string, unknown>[],
): [Record<string, unknown>[], ExpenseItem[], Record<string, unknown>[]] => {
static diffDBEntries = (baseItems: ExpenseItem[], itemsData: Record<string, unknown>[]): ExpenseItemsDiff => {
return diffDBEntries(baseItems, itemsData, ExpenseItem.editableFields);
};

Expand Down
2 changes: 1 addition & 1 deletion server/models/HostApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class HostApplication extends Model<InferAttributes<HostApplication>, Inf
public declare readonly id: CreationOptional<number>;
public declare CollectiveId: number;
public declare HostCollectiveId: number;
public CreatedByUserId: number;
public declare CreatedByUserId: number;
public declare status: HostApplicationStatus;
public declare customData: Record<string, unknown> | null;
public declare message: string;
Expand Down
5 changes: 3 additions & 2 deletions test/server/graphql/v2/mutation/ExpenseMutations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -418,13 +418,14 @@ describe('server/graphql/v2/mutation/ExpenseMutations', () => {
const updatedExpenseData = {
id: idEncode(expense.id, IDENTIFIER_TYPES.EXPENSE),
items: [
pick(items[0], ['id', 'url', 'amount']), // Don't change the first one (value=2000)
{ ...pick(items[1], ['id', 'url']), amount: 7000 }, // Update amount for the second one
convertExpenseItemId(pick(items[0]['dataValues'], ['id', 'url', 'amount'])), // Don't change the first one (value=2000)
convertExpenseItemId({ ...pick(items[1]['dataValues'], ['id', 'url']), amount: 7000 }), // Update amount for the second one
{ amount: 8000, url: randUrl() }, // Remove the third one and create another instead
],
};

const result = await graphqlQueryV2(editExpenseMutation, { expense: updatedExpenseData }, expense.User);
result.errors && console.error(result.errors);
expect(result.errors).to.not.exist;
const returnedItems = result.data.editExpense.items;
const sumItems = returnedItems.reduce((total, item) => total + item.amount, 0);
Expand Down
2 changes: 1 addition & 1 deletion test/test-helpers/fake-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ export const fakeUpdate = async (updateData: Record<string, unknown> = {}, seque
export const fakeExpenseItem = async (attachmentData: Record<string, unknown> = {}) => {
return models.ExpenseItem.create({
amount: randAmount(),
url: attachmentData.url || `${randUrl()}.pdf`,
url: <string>attachmentData.url || `${randUrl()}.pdf`,
description: randStr(),
...attachmentData,
ExpenseId: attachmentData.ExpenseId || (await fakeExpense({ items: [] })).id,
Expand Down