Skip to content

Commit

Permalink
Admin - Extend site edit functionality (#958)
Browse files Browse the repository at this point in the history
Co-authored-by: Eric Boucher <[email protected]>
  • Loading branch information
echaidemenos and ericboucher authored Dec 18, 2023
1 parent b91e0b2 commit fe25a19
Show file tree
Hide file tree
Showing 35 changed files with 640 additions and 98 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddContactInformationColumn1702565409448
implements MigrationInterface
{
name = 'AddContactInformationColumn1702565409448';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "site" ADD "contact_information" character varying`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "site" DROP COLUMN "contact_information"`,
);
}
}
21 changes: 19 additions & 2 deletions packages/api/src/collections/collections.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export const collectionTests = () => {
'createdAt',
'updatedAt',
'spotterApiToken',
'contactInformation',
),
);
expect(sortedSites[1]).toMatchObject(
Expand All @@ -186,6 +187,7 @@ export const collectionTests = () => {
'createdAt',
'updatedAt',
'spotterApiToken',
'contactInformation',
),
);

Expand Down Expand Up @@ -231,7 +233,14 @@ export const collectionTests = () => {

const sortedSites = sortBy(rsp.body.sites, (o) => o.name);
expect(sortedSites[0]).toMatchObject(
omit(athensSite, 'applied', 'createdAt', 'updatedAt', 'spotterApiToken'),
omit(
athensSite,
'applied',
'createdAt',
'updatedAt',
'spotterApiToken',
'contactInformation',
),
);
expect(sortedSites[1]).toMatchObject(
omit(
Expand All @@ -240,10 +249,18 @@ export const collectionTests = () => {
'createdAt',
'updatedAt',
'spotterApiToken',
'contactInformation',
),
);
expect(sortedSites[2]).toMatchObject(
omit(floridaSite, 'applied', 'createdAt', 'updatedAt', 'spotterApiToken'),
omit(
floridaSite,
'applied',
'createdAt',
'updatedAt',
'spotterApiToken',
'contactInformation',
),
);

const athensLatestData = getLatestData(athensTimeSeries);
Expand Down
12 changes: 2 additions & 10 deletions packages/api/src/monitoring/dto/get-sites-overview.dto.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsEmail,
IsEnum,
IsNumber,
IsOptional,
Validate,
} from 'class-validator';
import { IsEnum, IsOptional, Validate } from 'class-validator';
import { Site, SiteStatus } from 'sites/sites.entity';
import { EntityExists } from 'validations/entity-exists.constraint';

export class GetSitesOverview {
export class GetSitesOverviewDto {
@ApiProperty({ example: 42 })
@IsOptional()
@IsNumber()
@Validate(EntityExists, [Site])
siteId?: number;

Expand All @@ -30,7 +23,6 @@ export class GetSitesOverview {
@ApiProperty({ example: '[email protected]' })
@Type(() => String)
@IsOptional()
@IsEmail()
adminEmail?: string;

@ApiProperty({ example: 'John Smith' })
Expand Down
10 changes: 9 additions & 1 deletion packages/api/src/monitoring/monitoring.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Auth } from 'auth/auth.decorator';
import { AuthRequest } from 'auth/auth.types';
import { AdminLevel } from 'users/users.entity';
import { GetMonitoringStatsDto } from './dto/get-monitoring-stats.dto';
import { GetSitesOverviewDto } from './dto/get-sites-overview.dto';
import { PostMonitoringMetricDto } from './dto/post-monitoring-metric.dto';
import { MonitoringService } from './monitoring.service';

Expand Down Expand Up @@ -58,7 +59,14 @@ export class MonitoringController {
@Get('sites-overview')
@ApiOperation({ summary: 'Get Aqualink overview' })
@Auth(AdminLevel.SuperAdmin)
getSitesOverview(@Query() getSitesOverviewDto: GetMonitoringStatsDto) {
getSitesOverview(@Query() getSitesOverviewDto: GetSitesOverviewDto) {
return this.monitoringService.SitesOverview(getSitesOverviewDto);
}

@Get('sites-status')
@ApiOperation({ summary: "Get sites' status" })
@Auth(AdminLevel.SuperAdmin)
getSitesStatus() {
return this.monitoringService.getSitesStatus();
}
}
53 changes: 41 additions & 12 deletions packages/api/src/monitoring/monitoring.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { LatestData } from 'time-series/latest-data.entity';
import { IsNull, Not, Repository } from 'typeorm';
import { AdminLevel, User } from 'users/users.entity';
import { getDefaultDates } from 'utils/dates';
import { GetSitesOverview } from './dto/get-sites-overview.dto';
import { GetSitesOverviewDto } from './dto/get-sites-overview.dto';
import { GetMonitoringStatsDto } from './dto/get-monitoring-stats.dto';
import { PostMonitoringMetricDto } from './dto/post-monitoring-metric.dto';
import { Monitoring } from './monitoring.entity';
Expand All @@ -26,6 +26,10 @@ interface GetMetricsForSitesProps {
endDate?: Date;
}

function escapeLikeString(raw: string): string {
return raw.replace(/[\\%_]/g, '\\$&');
}

@Injectable()
export class MonitoringService {
constructor(
Expand Down Expand Up @@ -172,7 +176,7 @@ export class MonitoringService {
: null;

if (spotterSite === null && spotterId) {
throw new BadRequestException('Invalid parameter: spotterId');
throw new BadRequestException('Invalid value for parameter: spotterId');
}

const querySiteIds = siteIds || [spotterSite!.id];
Expand Down Expand Up @@ -244,7 +248,7 @@ export class MonitoringService {
adminUsername,
organization,
status,
}: GetSitesOverview) {
}: GetSitesOverviewDto) {
const latestDataSubQuery = this.latestDataRepository
.createQueryBuilder('latest_data')
.select(
Expand All @@ -257,7 +261,7 @@ export class MonitoringService {
const surveysCountSubQuery = this.surveyRepository
.createQueryBuilder('survey')
.select('survey.site_id', 'site_id')
.addSelect('COUNT(*)', 'surveysCount')
.addSelect('COUNT(*)', 'count')
.groupBy('survey.site_id');

const baseQuery = this.siteRepository
Expand All @@ -273,7 +277,8 @@ export class MonitoringService {
.addSelect('site.video_stream', 'videoStream')
.addSelect('site.updated_at', 'updatedAt')
.addSelect('latest_data.timestamp', 'lastDataReceived')
.addSelect('COALESCE("surveys_count"."surveysCount", 0)', 'surveysCount')
.addSelect('COALESCE(surveys_count.count, 0)', 'surveysCount')
.addSelect('site.contact_information', 'contactInformation')
.leftJoin(
'users_administered_sites_site',
'uass',
Expand All @@ -296,26 +301,30 @@ export class MonitoringService {
: baseQuery;

const withSiteName = siteName
? withSiteId.andWhere('site.name = :siteName', { siteName })
? withSiteId.andWhere('site.name ILIKE :siteName', {
siteName: `%${escapeLikeString(siteName)}%`,
})
: withSiteId;

const withSpotterId = spotterId
? withSiteName.andWhere('site.sensor_id = :spotterId', { spotterId })
: withSiteName;

const withAdminEmail = adminEmail
? withSpotterId.andWhere('u.email = :adminEmail', { adminEmail })
? withSpotterId.andWhere('u.email ILIKE :adminEmail', {
adminEmail: `%${escapeLikeString(adminEmail)}%`,
})
: withSpotterId;

const withAdminUserName = adminUsername
? withAdminEmail.andWhere('u.full_name = :adminUsername', {
adminUsername,
? withAdminEmail.andWhere('u.full_name ILIKE :adminUsername', {
adminUsername: `%${escapeLikeString(adminUsername)}%`,
})
: withAdminEmail;

const withOrganization = organization
? withAdminUserName.andWhere('u.organization = :organization', {
organization,
? withAdminUserName.andWhere('u.organization ILIKE :organization', {
organization: `%${escapeLikeString(organization)}%`,
})
: withAdminUserName;

Expand All @@ -332,8 +341,28 @@ export class MonitoringService {
.addGroupBy('site.video_stream')
.addGroupBy('site.updated_at')
.addGroupBy('latest_data.timestamp')
.addGroupBy('"surveys_count"."surveysCount"');
.addGroupBy('surveys_count.count')
.addGroupBy('site.contact_information');

return ret.getRawMany();
}

getSitesStatus() {
return this.siteRepository
.createQueryBuilder('site')
.select('COUNT(*)', 'totalSites')
.addSelect("COUNT(*) FILTER (WHERE site.status = 'deployed')", 'deployed')
.addSelect('COUNT(*) FILTER (WHERE site.display)', 'displayed')
.addSelect(
"COUNT(*) FILTER (WHERE site.status = 'maintenance')",
'maintenance',
)
.addSelect("COUNT(*) FILTER (WHERE site.status = 'shipped')", 'shipped')
.addSelect(
"COUNT(*) FILTER (WHERE site.status = 'end_of_life')",
'endOfLife',
)
.addSelect("COUNT(*) FILTER (WHERE site.status = 'lost')", 'lost')
.getRawOne();
}
}
18 changes: 18 additions & 0 deletions packages/api/src/monitoring/monitoring.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,5 +117,23 @@ export const monitoringTests = () => {
expect(rsp.body[0].updatedAt).toBeDefined();
expect(rsp.body[0].lastDataReceived).toBeDefined();
expect(rsp.body[0].surveysCount).toBeDefined();
expect(rsp.body[0].contactInformation).toBeDefined();
});

it('GET /sites-status', async () => {
mockExtractAndVerifyToken(adminFirebaseUserMock);
const rsp = await request(app.getHttpServer())
.get('/monitoring/sites-status')
.send();

expect(rsp.status).toBe(200);

expect(rsp.body.totalSites).toBeDefined();
expect(rsp.body.deployed).toBeDefined();
expect(rsp.body.displayed).toBeDefined();
expect(rsp.body.maintenance).toBeDefined();
expect(rsp.body.shipped).toBeDefined();
expect(rsp.body.endOfLife).toBeDefined();
expect(rsp.body.lost).toBeDefined();
});
};
24 changes: 22 additions & 2 deletions packages/api/src/sites/dto/update-site.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import {
IsLongitude,
IsObject,
ValidateNested,
IsEnum,
} from 'class-validator';
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import { SiteStatus } from 'sites/sites.entity';
import { EntityExists } from '../../validations/entity-exists.constraint';
import { Region } from '../../regions/regions.entity';
import { User } from '../../users/users.entity';
Expand Down Expand Up @@ -49,7 +51,7 @@ export class UpdateSiteDto {

@IsOptional()
@IsUrl()
readonly videoStream?: string;
readonly videoStream?: string | null;

@ApiProperty({ example: 1 })
@IsOptional()
Expand Down Expand Up @@ -82,4 +84,22 @@ export class UpdateSiteDto {
@IsNotEmpty()
@MaxLength(100)
readonly spotterApiToken?: string | null;

@ApiProperty({ example: 'deployed' })
@IsOptional()
@IsEnum(SiteStatus)
readonly status?: SiteStatus;

@IsOptional()
@Transform(({ value }) => {
return [true, 'true', 1, '1'].indexOf(value) > -1;
})
readonly display?: boolean;

@ApiProperty({ example: 'email: [email protected]' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(100)
readonly contactInformation?: string | null;
}
14 changes: 13 additions & 1 deletion packages/api/src/sites/sites.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,9 @@ export class SitesController {
update(
@Param('siteId', ParseIntPipe) id: number,
@Body() updateSiteDto: UpdateSiteDto,
@Req() request: AuthRequest,
): Promise<Site> {
return this.sitesService.update(id, updateSiteDto);
return this.sitesService.update(id, updateSiteDto, request.user);
}

@ApiBearerAuth()
Expand Down Expand Up @@ -196,4 +197,15 @@ export class SitesController {
): Promise<ExclusionDates[]> {
return this.sitesService.getExclusionDates(id);
}

@ApiBearerAuth()
@ApiOperation({
summary: 'Returns sites contact information notes',
})
@ApiParam({ name: 'siteId', example: 1 })
@Auth(AdminLevel.SuperAdmin)
@Get(':siteId/contact_info')
getContactInformation(@Param('siteId', ParseIntPipe) id: number) {
return this.sitesService.getContactInformation(id);
}
}
5 changes: 5 additions & 0 deletions packages/api/src/sites/sites.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ export class Site {
@Column({ nullable: true, select: false, type: 'character varying' })
spotterApiToken?: string | null;

@ApiHideProperty()
@Exclude()
@Column({ nullable: true, select: false, type: 'character varying' })
contactInformation?: string | null;

hasHobo: boolean;

collectionData?: CollectionDataDto;
Expand Down
Loading

0 comments on commit fe25a19

Please sign in to comment.