diff --git a/migrations/20241120004033-create-calculate-last-day-usage-procedure.js b/migrations/20241120004033-create-calculate-last-day-usage-procedure.js index 6bcc0971..227b6591 100644 --- a/migrations/20241120004033-create-calculate-last-day-usage-procedure.js +++ b/migrations/20241120004033-create-calculate-last-day-usage-procedure.js @@ -38,8 +38,6 @@ module.exports = { (f.status != 'DELETED' AND f.created_at BETWEEN CURRENT_DATE - INTERVAL '1 day' AND CURRENT_DATE - INTERVAL '1 millisecond') OR (f.status = 'DELETED' AND f.updated_at BETWEEN CURRENT_DATE - INTERVAL '1 day' AND CURRENT_DATE - INTERVAL '1 millisecond') - OR - (f.status != 'DELETED' AND f.modification_time BETWEEN CURRENT_DATE - INTERVAL '1 day' AND CURRENT_DATE - INTERVAL '1 millisecond') ) GROUP BY u.uuid; diff --git a/migrations/20241120010831-create-yearly-usage-procedure.js b/migrations/20241120010831-create-yearly-usage-procedure.js index 6ffc3d04..087b286e 100644 --- a/migrations/20241120010831-create-yearly-usage-procedure.js +++ b/migrations/20241120010831-create-yearly-usage-procedure.js @@ -18,7 +18,7 @@ module.exports = { WHERE period >= date_trunc('year', CURRENT_DATE) - INTERVAL '1 year' AND period < date_trunc('year', CURRENT_DATE) - AND type = 'monthly' + AND type IN ('monthly', 'daily') GROUP BY user_id ) @@ -46,7 +46,7 @@ module.exports = { WHERE period >= date_trunc('year', CURRENT_DATE) - INTERVAL '1 year' AND period < date_trunc('year', CURRENT_DATE) - AND type = 'monthly'; + AND type IN ('monthly', 'daily'); END; $$; `); diff --git a/migrations/20241120160129-create-updated-at-index-non-deleted-files.js b/migrations/20241120160129-create-updated-at-index-non-deleted-files.js deleted file mode 100644 index 19f7a912..00000000 --- a/migrations/20241120160129-create-updated-at-index-non-deleted-files.js +++ /dev/null @@ -1,19 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface) { - await queryInterface.sequelize.query( - ` - CREATE INDEX CONCURRENTLY files_updated_at_index - ON files USING btree (status, updated_at) - `, - ); - }, - - async down(queryInterface) { - await queryInterface.sequelize.query( - `DROP INDEX CONCURRENTLY files_updated_at_index`, - ); - }, -}; diff --git a/src/modules/usage/usage.domain.spec.ts b/src/modules/usage/usage.domain.spec.ts new file mode 100644 index 00000000..ad794450 --- /dev/null +++ b/src/modules/usage/usage.domain.spec.ts @@ -0,0 +1,44 @@ +import { v4 } from 'uuid'; +import { Usage, UsageType } from './usage.domain'; + +describe('Usage Domain', () => { + const usageAttributes = { + id: v4(), + userId: v4(), + delta: 100, + period: new Date(), + type: UsageType.Daily, + createdAt: new Date(), + updatedAt: new Date(), + }; + + it('When Usage type is Yearly, then isYearly should return true', () => { + const usage = Usage.build({ ...usageAttributes, type: UsageType.Yearly }); + + expect(usage.isYearly()).toBe(true); + expect(usage.isMonthly()).toBe(false); + expect(usage.isDaily()).toBe(false); + }); + + it('When Usage type is Monthly, then isMonthly should return true', () => { + const usage = Usage.build({ ...usageAttributes, type: UsageType.Monthly }); + + expect(usage.isYearly()).toBe(false); + expect(usage.isMonthly()).toBe(true); + expect(usage.isDaily()).toBe(false); + }); + + it('When Usage type is Daily, then isDaily should return true', () => { + const usage = Usage.build({ ...usageAttributes, type: UsageType.Daily }); + + expect(usage.isYearly()).toBe(false); + expect(usage.isMonthly()).toBe(false); + expect(usage.isDaily()).toBe(true); + }); + + it('When instance is created, then period should be parsed to date', () => { + const usage = Usage.build(usageAttributes); + + expect(usage.period).toBeInstanceOf(Date); + }); +}); diff --git a/src/modules/usage/usage.domain.ts b/src/modules/usage/usage.domain.ts index aef0a7a7..218170de 100644 --- a/src/modules/usage/usage.domain.ts +++ b/src/modules/usage/usage.domain.ts @@ -55,16 +55,4 @@ export class Usage implements UsageAttributes { isDaily(): boolean { return this.type === UsageType.Daily; } - - toJSON(): Partial { - return { - id: this.id, - userId: this.userId, - delta: this.delta, - period: this.period, - type: this.type, - createdAt: this.createdAt, - updatedAt: this.updatedAt, - }; - } } diff --git a/src/modules/usage/usage.repository.ts b/src/modules/usage/usage.repository.ts index 1870842c..47194889 100644 --- a/src/modules/usage/usage.repository.ts +++ b/src/modules/usage/usage.repository.ts @@ -1,7 +1,8 @@ import { InjectModel } from '@nestjs/sequelize'; import { Injectable } from '@nestjs/common'; import { UsageModel } from './usage.model'; -import { Usage } from './usage.domain'; +import { Usage, UsageType } from './usage.domain'; +import { Op } from 'sequelize'; @Injectable() export class SequelizeUsageRepository { @@ -24,9 +25,14 @@ export class SequelizeUsageRepository { return this.toDomain(newUsage); } - public async getMostRecentUsage(userUuid: string): Promise { + public async getMostRecentMonthlyOrYearlyUsage( + userUuid: string, + ): Promise { const mostRecentUsage = await this.usageModel.findOne({ - where: { userId: userUuid }, + where: { + userId: userUuid, + [Op.or]: [{ type: UsageType.Monthly }, { type: UsageType.Yearly }], + }, order: [['period', 'DESC']], }); @@ -45,7 +51,7 @@ export class SequelizeUsageRepository { return mostRecentUsage ? this.toDomain(mostRecentUsage) : null; } - public async addFirstDailyUsage(userUuid: string): Promise { + public async addFirstMonthlyUsage(userUuid: string): Promise { const query = ` INSERT INTO public.usages (id, user_id, delta, period, type, created_at, updated_at) SELECT @@ -79,66 +85,85 @@ export class SequelizeUsageRepository { public async getUserUsage(userUuid: string) { const query = ` WITH yearly_sums AS ( - SELECT - date_trunc('year', period) AS year, - SUM(delta) AS total_delta - FROM - public.usages - WHERE - type = 'yearly' - AND user_id = :userUuid - GROUP BY - date_trunc('year', period) - ), - monthly_sums AS ( - SELECT - date_trunc('year', period) AS year, - SUM(delta) AS total_delta - FROM - public.usages - WHERE - type = 'monthly' - AND user_id = :userUuid - GROUP BY - date_trunc('year', period) - ), - filtered_monthly_sums AS ( - SELECT - m.year, - m.total_delta - FROM - monthly_sums m - LEFT JOIN yearly_sums y ON m.year = y.year - WHERE y.year IS NULL - ), - combined_sums AS ( - SELECT - year, - total_delta - FROM - yearly_sums - UNION ALL - SELECT - year, - total_delta - FROM - filtered_monthly_sums - ) - SELECT - SUM( - CASE - WHEN year < date_trunc('year', CURRENT_DATE) THEN total_delta - ELSE 0 - END - ) AS total_yearly_delta, - SUM( - CASE - WHEN year = date_trunc('year', CURRENT_DATE) THEN total_delta - ELSE 0 - END - ) AS total_monthly_delta - FROM - combined_sums; + SELECT + date_trunc('year', period) AS year, + SUM(delta) AS total_delta + FROM + public.usages + WHERE + type = 'yearly' + AND user_id = :userUuid + GROUP BY + date_trunc('year', period) + ), + monthly_sums AS ( + SELECT + date_trunc('year', period) AS year, + date_trunc('month', period) AS month, + SUM(delta) AS total_delta + FROM + public.usages + WHERE + type = 'monthly' + AND user_id = :userUuid + GROUP BY + date_trunc('year', period), date_trunc('month', period) + ), + daily_sums AS ( + SELECT + date_trunc('year', period) AS year, + date_trunc('month', period) AS month, + SUM(delta) AS total_delta + FROM + public.usages + WHERE + type = 'daily' + AND user_id = :userUuid + GROUP BY + date_trunc('year', period), date_trunc('month', period) + ), + combined_monthly_and_daily AS ( + SELECT + COALESCE(m.year, d.year) AS year, + COALESCE(m.month, d.month) AS month, + COALESCE(m.total_delta, 0) + COALESCE(d.total_delta, 0) AS total_delta + FROM + monthly_sums m + FULL JOIN daily_sums d ON m.year = d.year AND m.month = d.month + ), + combined_sums AS ( + SELECT + y.year, + NULL AS month, + y.total_delta AS total_delta + FROM + yearly_sums y + UNION ALL + SELECT + cmd.year, + cmd.month, + cmd.total_delta + FROM + combined_monthly_and_daily cmd + LEFT JOIN yearly_sums ys ON cmd.year = ys.year + WHERE + ys.year IS NULL -- Exclude months and days where a yearly row exists + ) + SELECT + SUM( + CASE + WHEN year < date_trunc('year', CURRENT_DATE) THEN total_delta + ELSE 0 + END + ) AS total_yearly_delta, + SUM( + CASE + WHEN year = date_trunc('year', CURRENT_DATE) THEN total_delta + ELSE 0 + END + ) AS total_monthly_delta + FROM + combined_sums; `; const [result] = (await this.usageModel.sequelize.query(query, { diff --git a/src/modules/usage/usage.usecase.ts b/src/modules/usage/usage.usecase.ts index b8da4e03..29b2fe85 100644 --- a/src/modules/usage/usage.usecase.ts +++ b/src/modules/usage/usage.usecase.ts @@ -18,18 +18,28 @@ export class UsageUseCases { const userUuid = user.uuid; let mostRecentUsage = - await this.usageRepository.getMostRecentUsage(userUuid); + await this.usageRepository.getMostRecentMonthlyOrYearlyUsage(userUuid); if (!mostRecentUsage) { - mostRecentUsage = await this.usageRepository.addFirstDailyUsage(userUuid); + mostRecentUsage = + await this.usageRepository.addFirstMonthlyUsage(userUuid); } - const mostRecentUsageNextDay = new Date(mostRecentUsage.period); - mostRecentUsageNextDay.setUTCDate(mostRecentUsageNextDay.getUTCDate() + 1); + const calculateSizeChangesSince = new Date(mostRecentUsage.period); + + if (mostRecentUsage.isYearly()) { + calculateSizeChangesSince.setUTCFullYear( + calculateSizeChangesSince.getUTCFullYear() + 1, + ); + } else { + calculateSizeChangesSince.setUTCDate( + calculateSizeChangesSince.getUTCDate() + 1, + ); + } const totalStorageChanged = await this.fileRepository.sumFileSizesSinceDate( user.id, - mostRecentUsageNextDay, + calculateSizeChangesSince, ); const totalUsage = await this.usageRepository.getUserUsage(userUuid); @@ -108,15 +118,23 @@ export class UsageUseCases { user: User, oldFileData: File, newFileData: File, - ) { - const isFileCreatedToday = Time.isToday(newFileData.createdAt); + ): Promise { + const maybeExistentUsage = + await this.usageRepository.getMostRecentMonthlyOrYearlyUsage(user.uuid); - if (isFileCreatedToday) { - return; + if (!maybeExistentUsage) { + return null; } const delta = Number(newFileData.size) - Number(oldFileData.size); + // Files created the same day are going to be included in the next cronjob run + const isFileCreatedToday = Time.isToday(newFileData.createdAt); + + if (delta === 0 || isFileCreatedToday) { + return null; + } + return this.createDailyUsage(user.uuid, new Date(), delta); } }