Skip to content

New crag, Edit crag basics #179

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

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
33 changes: 31 additions & 2 deletions src/crags/dtos/create-crag.input.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { InputType, Field } from '@nestjs/graphql';
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsOptional } from 'class-validator';
import { CragType } from '../entities/crag.entity';
import {
CragType,
Orientation,
Season,
WallAngle,
} from '../entities/crag.entity';
import { PublishStatus } from '../entities/enums/publish-status.enum';

@InputType()
Expand Down Expand Up @@ -45,4 +50,28 @@ export class CreateCragInput {

@Field()
defaultGradingSystemId: string;

@Field(() => [Orientation], { nullable: true })
@IsOptional()
orientations: Orientation[];

@Field(() => Int, { nullable: true })
@IsOptional()
approachTime: number;

@Field(() => [WallAngle], { nullable: true })
@IsOptional()
wallAngles: WallAngle[];

@Field(() => [Season], { nullable: true })
@IsOptional()
seasons: Season[];

@Field({ nullable: true })
@IsOptional()
rainproof: boolean;

@Field({ nullable: true })
@IsOptional()
coverImageId: string;
}
10 changes: 10 additions & 0 deletions src/crags/dtos/merge-routes.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { InputType, Field } from '@nestjs/graphql';

@InputType()
export class MergeRoutesInput {
@Field()
sourceRouteId: string;

@Field()
targetRouteId: string;
}
10 changes: 10 additions & 0 deletions src/crags/dtos/move-routes-to-sector.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { InputType, Field } from '@nestjs/graphql';

@InputType()
export class MoveRoutesToSectorInput {
@Field((type) => [String])
ids: string[];

@Field()
sectorId: string;
}
33 changes: 31 additions & 2 deletions src/crags/dtos/update-crag.input.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { InputType, Field } from '@nestjs/graphql';
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsOptional } from 'class-validator';
import { CragType } from '../entities/crag.entity';
import {
CragType,
Orientation,
Season,
WallAngle,
} from '../entities/crag.entity';
import { PublishStatus } from '../entities/enums/publish-status.enum';

@InputType()
Expand Down Expand Up @@ -63,4 +68,28 @@ export class UpdateCragInput {
@Field({ nullable: true })
@IsOptional()
rejectionMessage: string;

@Field(() => [Orientation], { nullable: true })
@IsOptional()
orientations: Orientation[];

@Field(() => Int, { nullable: true })
@IsOptional()
approachTime: number;

@Field(() => [WallAngle], { nullable: true })
@IsOptional()
wallAngles: WallAngle[];

@Field(() => [Season], { nullable: true })
@IsOptional()
seasons: Season[];

@Field({ nullable: true })
@IsOptional()
rainproof: boolean;

@Field({ nullable: true })
@IsOptional()
coverImageId: string;
}
23 changes: 23 additions & 0 deletions src/crags/resolvers/crags.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,29 @@ export class CragsResolver {
return this.cragsService.delete(id);
}

/*
Merges all routes into one sector with no name, which yields a 'sectorless' crag
*/
@Mutation(() => Boolean)
@UseGuards(UserAuthGuard)
@UseInterceptors(AuditInterceptor)
@UseFilters(NotFoundFilter)
async mergeAllSectors(
@Args('cragId') cragId: string,
@CurrentUser() user: User,
): Promise<boolean> {
const crag = await this.cragsService.findOne({
id: cragId,
user,
});

if (!user.isAdmin() && crag.publishStatus != 'draft') {
throw new ForbiddenException();
}

return this.cragsService.mergeAllSectors(cragId);
}

/* FIELDS */

@ResolveField('nrRoutes', () => Int)
Expand Down
97 changes: 95 additions & 2 deletions src/crags/resolvers/routes.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ import { StarRatingVotesService } from '../services/star-rating-votes.service';
import { StarRatingVote } from '../entities/star-rating-vote.entity';
import { FindDifficultyVotesInput } from '../dtos/find-difficulty-votes.input';
import { FindStarRatingVotesInput } from '../dtos/find-star-rating-votes.input';
import { MergeRoutesInput } from '../dtos/merge-routes.input';
import { MoveRoutesToSectorInput } from '../dtos/move-routes-to-sector.input';

@Resolver(() => Route)
@UseInterceptors(DataLoaderInterceptor)
Expand Down Expand Up @@ -132,7 +134,7 @@ export class RoutesResolver {
user,
});

if (!user.isAdmin() && route.publishStatus != 'draft') {
if (!(await user.isAdmin()) && route.publishStatus != 'draft') {
throw new ForbiddenException();
}

Expand Down Expand Up @@ -168,7 +170,13 @@ export class RoutesResolver {
@Args('input', { type: () => [UpdateRouteInput] })
input: UpdateRouteInput[],
): Promise<Route[]> {
return Promise.all(input.map((input) => this.routesService.update(input)));
// Process all routes updates sequentially. e.g. changing routes positions would be unpredictable otherwise
const result = [];
for (const routeInput of input) {
result.push(await this.routesService.update(routeInput));
}

return Promise.resolve(result);
}

@Mutation(() => Boolean)
Expand All @@ -191,6 +199,30 @@ export class RoutesResolver {
return this.routesService.delete(id);
}

@Mutation(() => [Boolean])
@UseGuards(UserAuthGuard)
@UseInterceptors(AuditInterceptor)
@UseFilters(NotFoundFilter, ForeignKeyConstraintFilter)
async deleteRoutes(
@Args('ids', { type: () => [String] }) ids: string[],
@CurrentUser() user: User,
): Promise<boolean[]> {
return Promise.all(
ids.map(async (id) => {
const route = await this.routesService.findOne({
id: id,
user,
});

if (!user.isAdmin() && route.publishStatus != 'draft') {
throw new ForbiddenException();
}

return this.routesService.delete(id);
}),
);
}

@Mutation(() => Boolean)
@UseGuards(UserAuthGuard)
@UseFilters(NotFoundFilter)
Expand Down Expand Up @@ -241,6 +273,67 @@ export class RoutesResolver {
return this.routesService.moveToSector(route, sector);
}

@Mutation(() => Boolean)
@UseGuards(UserAuthGuard)
@UseFilters(NotFoundFilter)
@UseInterceptors(AuditInterceptor)
async moveRoutesToSector(
@Args('input', { type: () => MoveRoutesToSectorInput })
input: MoveRoutesToSectorInput,
@CurrentUser() user: User,
): Promise<boolean> {
if (!user.isAdmin()) {
throw new ForbiddenException();
}

const sector = await this.sectorsService.findOne({
id: input.sectorId,
user,
});

return this.routesService.moveManyToSector(input.ids, sector);
}

@Mutation(() => Boolean)
@UseGuards(UserAuthGuard)
@UseFilters(NotFoundFilter)
@UseInterceptors(AuditInterceptor)
async mergeRoutes(
@Args('input', { type: () => MergeRoutesInput })
input: MergeRoutesInput,
@CurrentUser() user: User,
): Promise<boolean> {
if (!user.isAdmin()) {
throw new ForbiddenException();
}

const sourceRoute = await this.routesService.findOne({
id: input.sourceRouteId,
user,
});

const targetRoute = await this.routesService.findOne({
id: input.targetRouteId,
user,
});

if (
sourceRoute.publishStatus != 'published' ||
targetRoute.publishStatus != 'published'
) {
throw new BadRequestException('cannot_merge_unpublished_routes');
}

if (
(await sourceRoute.pitches).length ||
(await targetRoute.pitches).length
) {
throw new BadRequestException('cannot_merge_multipitch_routes');
}

return this.routesService.merge(sourceRoute, targetRoute);
}

/* FIELDS */

@ResolveField('comments', () => [Comment])
Expand Down
4 changes: 2 additions & 2 deletions src/crags/resolvers/sectors.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ export class SectorsResolver {
user,
});

if (!user.isAdmin() && sector.publishStatus != 'draft') {
if (!(await user.isAdmin()) && sector.publishStatus != 'draft') {
throw new ForbiddenException();
}

if (!user.isAdmin() && input.publishStatus == 'published') {
if (!(await user.isAdmin()) && input.publishStatus == 'published') {
throw new BadRequestException('publish_status_unavailable_to_user');
}

Expand Down
70 changes: 70 additions & 0 deletions src/crags/services/crags.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { setBuilderCache } from '../../core/utils/entity-cache/entity-cache-helpers';
import { Queue } from 'bull';
import { InjectQueue } from '@nestjs/bull';
import { Image } from '../entities/image.entity';

@Injectable()
export class CragsService {
Expand All @@ -32,6 +33,8 @@ export class CragsService {
protected cragsRepository: Repository<Crag>,
@InjectRepository(Country)
private countryRepository: Repository<Country>,
@InjectRepository(Image)
protected imagesRepository: Repository<Image>,
@InjectQueue('summary') private summaryQueue: Queue,
private dataSource: DataSource,
) {}
Expand Down Expand Up @@ -113,6 +116,13 @@ export class CragsService {

crag.slug = await this.generateCragSlug(crag.name, crag.id);

if (data.coverImageId) {
const coverImage = this.imagesRepository.findOneBy({
id: data.coverImageId,
});
crag.coverImage = coverImage;
}

await this.save(
crag,
await crag.user,
Expand Down Expand Up @@ -194,6 +204,66 @@ export class CragsService {
return Promise.resolve(true);
}

async mergeAllSectors(id: string): Promise<boolean> {
const crag = await this.cragsRepository.findOneOrFail({
where: { id: id },
relations: {
sectors: {
routes: true,
},
},
order: {
sectors: {
position: 'ASC',
routes: {
position: 'ASC',
},
},
},
});

const sectors = await crag.sectors;
const keptSector = sectors[0]; // keep first sector as the dummy sector for all routes
const keptSectorRoutes = await keptSector.routes;

let lastPosition =
keptSectorRoutes[keptSectorRoutes.length - 1]?.position || 0;

const transaction = new Transaction(this.dataSource);
await transaction.start();

try {
// move all routes into the dummy sector, adjusting route positions
for (let i = 1; i < sectors.length; i++) {
const otherSector = sectors[i];
const routesToMove = await otherSector.routes;

for (let routeToMove of routesToMove) {
routeToMove.sectorId = keptSector.id;
routeToMove.position = lastPosition + 1;
lastPosition++;
await transaction.save(routeToMove);
}

// delete all sectors (but first one)
await transaction.delete(otherSector);
}

// remove name and label of first sector (update them to empty string)
await transaction.queryRunner.manager.update(Sector, keptSector.id, {
label: '',
name: '',
});
} catch (e) {
await transaction.rollback();
throw e;
}

await transaction.commit();

return Promise.resolve(true);
}

private async buildQuery(
params: FindCragsServiceInput = {},
): Promise<SelectQueryBuilder<Crag>> {
Expand Down
Loading