From bf5d38280bc5878bb23a599b84b2fa68bc04ba9b Mon Sep 17 00:00:00 2001 From: Bryan Nelson Date: Mon, 17 Jun 2024 14:32:01 -0400 Subject: [PATCH 1/3] Refactor `PeriodicReport` service/repo layers --- .../dto/periodic-report.dto.ts | 2 +- .../periodic-report.repository.ts | 238 ++++++++++-------- .../periodic-report.service.ts | 60 ++--- 3 files changed, 155 insertions(+), 145 deletions(-) diff --git a/src/components/periodic-report/dto/periodic-report.dto.ts b/src/components/periodic-report/dto/periodic-report.dto.ts index 944b13b224..41d776e3be 100644 --- a/src/components/periodic-report/dto/periodic-report.dto.ts +++ b/src/components/periodic-report/dto/periodic-report.dto.ts @@ -49,7 +49,7 @@ class PeriodicReport extends Resource { @Field() readonly skippedReason: SecuredStringNullable; - readonly reportFile: DefinedFile; + readonly reportFile: DefinedFile; //TODO? - Secured | null> @SensitivityField({ description: "Based on the project's sensitivity", diff --git a/src/components/periodic-report/periodic-report.repository.ts b/src/components/periodic-report/periodic-report.repository.ts index 30bf69edce..612403c8a3 100644 --- a/src/components/periodic-report/periodic-report.repository.ts +++ b/src/components/periodic-report/periodic-report.repository.ts @@ -13,12 +13,13 @@ import { CalendarDate, generateId, ID, + NotFoundException, Range, + ServerException, Session, UnsecuredDto, } from '~/common'; import { DtoRepository } from '~/core/database'; -import { ChangesOf } from '~/core/database/changes'; import { ACTIVE, createNode, @@ -65,120 +66,139 @@ export class PeriodicReportRepository extends DtoRepository< } async merge(input: MergePeriodicReports) { - const Report = resolveReportType(input); + try { + const Report = resolveReportType(input); - // Create IDs here that will feed into the reports that are new. - // If only neo4j had a nanoid generator natively. - const intervals = await Promise.all( - input.intervals.map(async (interval) => ({ - tempId: await generateId(), - start: interval.start, - end: interval.end, - tempFileId: await generateId(), - })), - ); + // Create IDs here that will feed into the reports that are new. + // If only neo4j had a nanoid generator natively. + const intervals = await Promise.all( + input.intervals.map(async (interval) => ({ + tempId: await generateId(), + start: interval.start, + end: interval.end, + tempFileId: await generateId(), + })), + ); - const isProgress = input.type === ReportType.Progress; - const extraCreateOptions = isProgress - ? this.progressRepo.getCreateOptions(input) - : {}; + const isProgress = input.type === ReportType.Progress; + const extraCreateOptions = isProgress + ? this.progressRepo.getCreateOptions(input) + : {}; - const query = this.db - .query() - // before interval list, so it's the same time across reports - .with('datetime() as now') - .matchNode('parent', 'BaseNode', { id: input.parent }) - .unwind(intervals, 'interval') - .comment('Stop processing this row if the report already exists') - .subQuery('parent, interval', (sub) => - sub - .match([ - [ - node('parent'), - relation('out', '', 'report', ACTIVE), - node('node', `${input.type}Report`), - ], - [ - node('node'), - relation('out', '', 'start', ACTIVE), - node('', 'Property', { value: variable('interval.start') }), - ], - [ - node('node'), - relation('out', '', 'end', ACTIVE), - node('', 'Property', { value: variable('interval.end') }), - ], - ]) - // Invert zero rows into one row - // We want to continue out of this sub-query having 1 row when - // the report doesn't exist. - // However, the match above gives us zero rows in this case. - // Use count() to get us back to 1 row, and to create a temp list - // of how many rows we want (0 if report exists, 1 if it doesn't). - // Then use UNWIND to convert this list into rows. - .with('CASE WHEN count(node) = 0 THEN [true] ELSE [] END as rows') - .raw('UNWIND rows as row') - // nonsense value, the 1 row returned is what is important, not this column - .return('true as itIsNew'), - ) - .apply( - await createNode(Report as typeof IPeriodicReport, { - baseNodeProps: { - id: variable('interval.tempId'), - createdAt: variable('now'), - ...extraCreateOptions.baseNodeProps, - }, - initialProps: { - type: input.type, - start: variable('interval.start'), - end: variable('interval.end'), - skippedReason: null, - receivedDate: null, - reportFile: variable('interval.tempFileId'), - ...extraCreateOptions.initialProps, - }, - }), - ) - .apply( - createRelationships(Report, 'in', { - report: variable('parent'), - }), - ) - .apply(isProgress ? this.progressRepo.amendAfterCreateNode() : undefined) - // rename node to report, so we can call create node again for the file - .with('now, interval, node as report') - .apply( - await createNode(File, { - initialProps: { - name: variable('apoc.temporal.format(interval.end, "date")'), - }, - baseNodeProps: { - id: variable('interval.tempFileId'), - createdAt: variable('now'), - }, - }), - ) - .apply( - createRelationships(File, { - in: { reportFileNode: variable('report') }, - out: { createdBy: ['User', input.session.userId] }, - }), - ) - .return<{ id: ID; interval: Range }>( - 'report.id as id, interval', - ); - return await query.run(); + const query = this.db + .query() + // before interval list, so it's the same time across reports + .with('datetime() as now') + .matchNode('parent', 'BaseNode', { id: input.parent }) + .unwind(intervals, 'interval') + .comment('Stop processing this row if the report already exists') + .subQuery('parent, interval', (sub) => + sub + .match([ + [ + node('parent'), + relation('out', '', 'report', ACTIVE), + node('node', `${input.type}Report`), + ], + [ + node('node'), + relation('out', '', 'start', ACTIVE), + node('', 'Property', { value: variable('interval.start') }), + ], + [ + node('node'), + relation('out', '', 'end', ACTIVE), + node('', 'Property', { value: variable('interval.end') }), + ], + ]) + // Invert zero rows into one row + // We want to continue out of this sub-query having 1 row when + // the report doesn't exist. + // However, the match above gives us zero rows in this case. + // Use count() to get us back to 1 row, and to create a temp list + // of how many rows we want (0 if report exists, 1 if it doesn't). + // Then use UNWIND to convert this list into rows. + .with('CASE WHEN count(node) = 0 THEN [true] ELSE [] END as rows') + .raw('UNWIND rows as row') + // nonsense value, the 1 row returned is what is important, not this column + .return('true as itIsNew'), + ) + .apply( + await createNode(Report as typeof IPeriodicReport, { + baseNodeProps: { + id: variable('interval.tempId'), + createdAt: variable('now'), + ...extraCreateOptions.baseNodeProps, + }, + initialProps: { + type: input.type, + start: variable('interval.start'), + end: variable('interval.end'), + skippedReason: null, + receivedDate: null, + reportFile: variable('interval.tempFileId'), + ...extraCreateOptions.initialProps, + }, + }), + ) + .apply( + createRelationships(Report, 'in', { + report: variable('parent'), + }), + ) + .apply( + isProgress ? this.progressRepo.amendAfterCreateNode() : undefined, + ) + // rename node to report, so we can call create node again for the file + .with('now, interval, node as report') + .apply( + await createNode(File, { + initialProps: { + name: variable('apoc.temporal.format(interval.end, "date")'), + }, + baseNodeProps: { + id: variable('interval.tempFileId'), + createdAt: variable('now'), + }, + }), + ) + .apply( + createRelationships(File, { + in: { reportFileNode: variable('report') }, + out: { createdBy: ['User', input.session.userId] }, + }), + ) + .return<{ id: ID; interval: Range }>( + 'report.id as id, interval', + ); + + return await query.run(); + } catch (exception) { + throw new ServerException('Could not create periodic reports', exception); + } } - async update>( - existing: T, - simpleChanges: Omit< - ChangesOf, - 'reportFile' - > & - Partial>, + async update( + changes: Omit & + Pick, + session: Session, ) { - return await this.updateProperties(existing, simpleChanges); + const { id, ...simpleChanges } = changes; + + await this.updateProperties({ id }, simpleChanges); + + return await this.readOne(id, session); + } + + async readOne(id: ID, session: Session) { + if (!id) { + throw new NotFoundException( + 'No periodic report id to search for', + 'periodicReport.id', + ); + } + + return await super.readOne(id, session); } async list(input: PeriodicReportListInput, session: Session) { diff --git a/src/components/periodic-report/periodic-report.service.ts b/src/components/periodic-report/periodic-report.service.ts index fbc128f032..c41ef09fa2 100644 --- a/src/components/periodic-report/periodic-report.service.ts +++ b/src/components/periodic-report/periodic-report.service.ts @@ -3,10 +3,8 @@ import { CalendarDate, DateInterval, ID, - NotFoundException, ObjectView, Range, - ServerException, Session, UnsecuredDto, } from '~/common'; @@ -44,36 +42,42 @@ export class PeriodicReportService { if (input.intervals.length === 0) { return; } - try { - const result = await this.repo.merge(input); - this.logger.info(`Merged ${input.type.toLowerCase()} reports`, { - existing: input.intervals.length - result.length, - new: result.length, - parent: input.parent, - newIntervals: result.map(({ interval }) => - DateInterval.fromObject(interval).toISO(), - ), - }); - } catch (exception) { - throw new ServerException('Could not create periodic reports', exception); - } + const result = await this.repo.merge(input); + this.logger.info(`Merged ${input.type.toLowerCase()} reports`, { + existing: input.intervals.length - result.length, + new: result.length, + parent: input.parent, + newIntervals: result.map(({ interval }) => + DateInterval.fromObject(interval).toISO(), + ), + }); } async update(input: UpdatePeriodicReportInput, session: Session) { - const currentRaw = await this.repo.readOne(input.id, session); - const current = this.secure(currentRaw, session); + const current = await this.repo.readOne(input.id, session); const changes = this.repo.getActualChanges(current, input); this.privileges - .for(session, resolveReportType(current), currentRaw) + .for(session, resolveReportType(current), current) .verifyChanges(changes); const { reportFile, ...simpleChanges } = changes; - const updated = await this.repo.update(current, simpleChanges); + const updated = this.secure( + await this.repo.update( + { + id: current.id, + start: current.start, + end: current.end, + ...simpleChanges, + }, + session, + ), + session, + ); if (reportFile) { const file = await this.files.updateDefinedFile( - current.reportFile, + this.secure(current, session).reportFile, 'file', reportFile, session, @@ -96,17 +100,6 @@ export class PeriodicReportService { session: Session, _view?: ObjectView, ): Promise { - this.logger.debug(`read one`, { - id, - userId: session.userId, - }); - if (!id) { - throw new NotFoundException( - 'No periodic report id to search for', - 'periodicReport.id', - ); - } - const result = await this.repo.readOne(id, session); return this.secure(result, session); } @@ -232,10 +225,7 @@ export class PeriodicReportService { // no change return; } - await this.repo.update(report, { - start: at, - end: at, - }); + await this.repo.update({ id: report.id, start: at, end: at }, session); } else { await this.merge({ intervals: [{ start: at, end: at }], From b5675f6ed98215f919810002eea28fad4fd398b2 Mon Sep 17 00:00:00 2001 From: Bryan Nelson Date: Tue, 18 Jun 2024 13:33:05 -0400 Subject: [PATCH 2/3] Create `PeriodicReport` queries --- .../dto/periodic-report.dto.ts | 13 ++ .../periodic-report.edgedb.repository.ts | 189 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 src/components/periodic-report/periodic-report.edgedb.repository.ts diff --git a/src/components/periodic-report/dto/periodic-report.dto.ts b/src/components/periodic-report/dto/periodic-report.dto.ts index 41d776e3be..3168cac4b5 100644 --- a/src/components/periodic-report/dto/periodic-report.dto.ts +++ b/src/components/periodic-report/dto/periodic-report.dto.ts @@ -1,5 +1,6 @@ import { Field, InterfaceType, ObjectType } from '@nestjs/graphql'; import { keys as keysOf } from 'ts-transformer-keys'; +import { MergeExclusive } from 'type-fest'; import { Calculated, CalendarDate, @@ -17,8 +18,14 @@ import { e } from '~/core/edgedb'; import { RegisterResource } from '~/core/resources'; import { ScopedRole } from '../../authorization/dto'; import { DefinedFile } from '../../file/dto'; +import { ProgressReport } from '../../progress-report/dto'; import { ReportType } from './report-type.enum'; +export type AnyReport = MergeExclusive< + FinancialReport, + MergeExclusive +>; + @RegisterResource({ db: e.PeriodicReport }) @Calculated() @InterfaceType({ @@ -92,6 +99,12 @@ export class NarrativeReport extends PeriodicReport { }) export class SecuredPeriodicReport extends SecuredProperty(PeriodicReport) {} +export const ReportConcretes = { + Financial: FinancialReport, + Narrative: NarrativeReport, + Progress: ProgressReport, +}; + declare module '~/core/resources/map' { interface ResourceMap { PeriodicReport: typeof PeriodicReport; diff --git a/src/components/periodic-report/periodic-report.edgedb.repository.ts b/src/components/periodic-report/periodic-report.edgedb.repository.ts new file mode 100644 index 0000000000..6a07783db1 --- /dev/null +++ b/src/components/periodic-report/periodic-report.edgedb.repository.ts @@ -0,0 +1,189 @@ +import { Injectable } from '@nestjs/common'; +import { Query } from 'cypher-query-builder'; +import { Without } from 'type-fest/source/merge-exclusive'; +import { + CalendarDate, + ID, + PublicOf, + Range, + Session, + UnsecuredDto, +} from '~/common'; +import { castToEnum, e, RepoFor } from '~/core/edgedb'; +import { Variable } from '../../core/database/query'; +import { ProgressReport } from '../progress-report/dto'; +import { + FinancialReport, + IPeriodicReport, + MergePeriodicReports, + NarrativeReport, + ReportType, + resolveReportType, +} from './dto'; +import { PeriodicReportRepository } from './periodic-report.repository'; + +@Injectable() +export class PeriodicReportEdgeDBRepository + extends RepoFor(IPeriodicReport, { + hydrate: (periodicReport) => ({ + ...periodicReport['*'], + type: castToEnum(periodicReport.__type__.name.slice(9, -7), ReportType), + reportFile: true, + sensitivity: periodicReport.container.is(e.Project.ContextAware) + .sensitivity, + scope: false, + parent: e.tuple({ + identity: periodicReport.id, + labels: e.array_agg(e.set(periodicReport.__type__.name.slice(9, null))), + properties: e.tuple({ + id: periodicReport.id, + createdAt: periodicReport.createdAt, + }), + }), + }), + }) + implements PublicOf +{ + merge( + input: MergePeriodicReports, + ): Promise }>> { + throw new Error('Method not implemented.'); + } + + matchCurrentDue( + parentId: ID | Variable, + reportType: ReportType, + ): (query: Query) => Query { + throw new Error('Method not implemented.'); + } + + getByDate( + parentId: ID, + date: CalendarDate, + reportType: ReportType, + _session: Session, + ) { + const resource = e.cast(e.Resource, e.uuid(parentId)); + + const report = e.select(e.PeriodicReport, (report) => ({ + filter: e.all( + e.set( + e.op(resource.id, '=', report.container.id), + e.op(report.start, '<=', date), + e.op(report.end, '>=', date), + ), + ), + ...report.is(resolveReportType(reportType)), + })); + + return this.db.run(report); + } + + getCurrentDue( + parentId: ID, + reportType: ReportType, + session: Session, + ): Promise< + | UnsecuredDto< + | (Without< + | (Without & NarrativeReport) + | (Without & FinancialReport), + ProgressReport + > & + ProgressReport) + | (Without< + ProgressReport, + | (Without & NarrativeReport) + | (Without & FinancialReport) + > & + ( + | (Without & NarrativeReport) + | (Without & FinancialReport) + )) + > + | undefined + > { + throw new Error('Method not implemented.'); + } + + getNextDue( + parentId: ID, + reportType: ReportType, + session: Session, + ): Promise< + | UnsecuredDto< + | (Without< + | (Without & NarrativeReport) + | (Without & FinancialReport), + ProgressReport + > & + ProgressReport) + | (Without< + ProgressReport, + | (Without & NarrativeReport) + | (Without & FinancialReport) + > & + ( + | (Without & NarrativeReport) + | (Without & FinancialReport) + )) + > + | undefined + > { + throw new Error('Method not implemented.'); + } + + getLatestReportSubmitted( + parentId: ID, + type: ReportType, + session: Session, + ): Promise< + | UnsecuredDto< + | (Without< + | (Without & NarrativeReport) + | (Without & FinancialReport), + ProgressReport + > & + ProgressReport) + | (Without< + ProgressReport, + | (Without & NarrativeReport) + | (Without & FinancialReport) + > & + ( + | (Without & NarrativeReport) + | (Without & FinancialReport) + )) + > + | undefined + > { + throw new Error('Method not implemented.'); + } + + getFinalReport( + parentId: ID, + type: ReportType, + session: Session, + ): Promise< + | UnsecuredDto< + | (Without< + | (Without & NarrativeReport) + | (Without & FinancialReport), + ProgressReport + > & + ProgressReport) + | (Without< + ProgressReport, + | (Without & NarrativeReport) + | (Without & FinancialReport) + > & + ( + | (Without & NarrativeReport) + | (Without & FinancialReport) + )) + > + | undefined + > { + throw new Error('Method not implemented.'); + } +} From ec5221059d64c69b1a99493366b44e0db88e2658 Mon Sep 17 00:00:00 2001 From: Bryan Nelson Date: Wed, 7 Aug 2024 23:15:02 -0400 Subject: [PATCH 3/3] Iterate on getByDate --- .../periodic-report.edgedb.repository.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/periodic-report/periodic-report.edgedb.repository.ts b/src/components/periodic-report/periodic-report.edgedb.repository.ts index 6a07783db1..5dbe7a68a3 100644 --- a/src/components/periodic-report/periodic-report.edgedb.repository.ts +++ b/src/components/periodic-report/periodic-report.edgedb.repository.ts @@ -3,6 +3,7 @@ import { Query } from 'cypher-query-builder'; import { Without } from 'type-fest/source/merge-exclusive'; import { CalendarDate, + EnhancedResource, ID, PublicOf, Range, @@ -57,15 +58,18 @@ export class PeriodicReportEdgeDBRepository throw new Error('Method not implemented.'); } - getByDate( + async getByDate( parentId: ID, date: CalendarDate, reportType: ReportType, _session: Session, ) { - const resource = e.cast(e.Resource, e.uuid(parentId)); + const enhancedResource = EnhancedResource.of( + resolveReportType({ type: reportType }), + ); + const resource = e.cast(enhancedResource.db, e.uuid(parentId)); - const report = e.select(e.PeriodicReport, (report) => ({ + const report = e.select(resource, (report) => ({ filter: e.all( e.set( e.op(resource.id, '=', report.container.id), @@ -73,10 +77,11 @@ export class PeriodicReportEdgeDBRepository e.op(report.end, '>=', date), ), ), - ...report.is(resolveReportType(reportType)), })); - return this.db.run(report); + const query = e.select(report, this.hydrate); + + return await this.db.run(query); } getCurrentDue(