feat: Introducing user-collections into self-host (HBE-98) (#18)

* feat: team module added

* feat: teamEnvironment module added

* feat: teamCollection module added

* feat: team request module added

* feat: team invitation module added

* feat: selfhost auth frontend (#15)

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>

* feat: bringing shortcodes from central to selfhost

* chore: added review changes in resolver

* chore: commented out subscriptions

* chore: bump backend prettier version

* feat: created new user-collections module with base files

* feat: added new models for user-collection and user-request tables in schema.prisma file

* feat: mutations to create user-collections complete

* feat: added user field resolver for userCollections

* feat: added parent field resolver for userCollections

* feat: added child field resolver with pagination for userCollections

* feat: added query to fetch root user-collections with pagination for userCollections

* feat: added query to fetch user-collections for userCollections

* feat: added mutation to rename user-collections

* feat: added mutation to delete user-collections

* feat: added mutation to delete user-collections

* refactor: changed the way we fetch root and child user-collection counts for other operations

* feat: added mutation to move user-collections between root and other child collections

* refactor: abstracted orderIndex update logic into helpert function

* chore: mutation to update order root user-collections complete

* feat: user-collections order can be updated when moving it to the end of list

* feat: user-collections order update feature complete

* feat: subscriptions for user-collection module complete

* chore: removed all console.logs from user-collection.service file

* test: added tests for all field resolvers for user-collection module

* test: test cases for getUserCollection is complete

* test: test cases for getUserRootCollections is complete

* test: test cases for createUserCollection is complete

* test: test cases for renameCollection is complete

* test: test cases for moveUserCollection is complete

* test: test cases for updateUserCollectionOrder is complete

* chore: added createdOn and updatedOn fields to userCollections and userRequests schema

* chore: created function to check if title are of valid size

* refactor: simplified user-collection creation code

* chore: made changed requested in initial PR review

* chore: added requestType enum to user-collections

* refactor: created two seperate queries to fetch root REST or GQL queries

* chore: created seperate mutations and queries for REST and GQL root/child collections

* chore: migrated all input args classess into a single file

* chore: modified createUserCollection service method to work with different creation inputs args type

* chore: rewrote all test cases for user-collections service methods with new CollType

* fix: added updated and deleted subscription changes

* fix: made all the changes requested in the initial PR review

* fix: made all the changes requested in the second PR review

* chore: removed migrations from prisma directory

* fix: made all the changes requested in the third PR review

* chore: added collection type checking to updateUserCollectionOrder service method

* chore: refactored all test cases to reflect new additions to service methods

* chore: fixed issues with pnpm-lock

* chore: removed migrations from prisma directory

* chore: hopefully fixed pnpm-lock issues

* chore: removed console logs in auth controller

---------

Co-authored-by: Mir Arif Hasan <arif.ishan05@gmail.com>
Co-authored-by: Akash K <57758277+amk-dev@users.noreply.github.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: ankitsridhar16 <ankit.sridhar16@gmail.com>
This commit is contained in:
Balu Babu
2023-03-03 15:03:05 +05:30
committed by GitHub
parent 31c6b0664f
commit a938be3712
14 changed files with 2642 additions and 3 deletions

View File

@@ -90,6 +90,8 @@ model User {
settings UserSettings?
UserHistory UserHistory[]
UserEnvironments UserEnvironment[]
userCollections UserCollection[]
userRequests UserRequest[]
currentRESTSession Json?
currentGQLSession Json?
createdOn DateTime @default(now()) @db.Timestamp(3)
@@ -152,6 +154,35 @@ model UserEnvironment {
isGlobal Boolean
}
model UserCollection {
id String @id @default(cuid())
parentID String?
parent UserCollection? @relation("ParentUserCollection", fields: [parentID], references: [id], onDelete: Cascade)
children UserCollection[] @relation("ParentUserCollection")
requests UserRequest[]
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
title String
orderIndex Int
type ReqType
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model UserRequest {
id String @id @default(cuid())
collectionID String
collection UserCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
title String
request Json
type ReqType
orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
enum TeamMemberRole {
OWNER
VIEWER

View File

@@ -13,6 +13,7 @@ import { TeamEnvironmentsModule } from './team-environments/team-environments.mo
import { TeamCollectionModule } from './team-collection/team-collection.module';
import { TeamRequestModule } from './team-request/team-request.module';
import { TeamInvitationModule } from './team-invitation/team-invitation.module';
import { UserCollectionModule } from './user-collection/user-collection.module';
import { ShortcodeModule } from './shortcode/shortcode.module';
import { COOKIES_NOT_FOUND } from './errors';
@@ -64,6 +65,7 @@ import { COOKIES_NOT_FOUND } from './errors';
TeamCollectionModule,
TeamRequestModule,
TeamInvitationModule,
UserCollectionModule,
ShortcodeModule,
],
providers: [GQLComplexityPlugin],

View File

@@ -17,10 +17,17 @@ export class GoogleStrategy extends PassportStrategy(Strategy) {
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
scope: process.env.GOOGLE_SCOPE.split(','),
passReqToCallback: true,
});
}
async validate(accessToken, refreshToken, profile, done: VerifyCallback) {
async validate(
req: Request,
accessToken,
refreshToken,
profile,
done: VerifyCallback,
) {
const user = await this.usersService.findUserByEmail(
profile.emails[0].value,
);

View File

@@ -373,3 +373,68 @@ export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const;
* (AuthService)
*/
export const INVALID_REFRESH_TOKEN = 'auth/invalid_refresh_token' as const;
/**
* The provided title for the user collection is short (less than 3 characters)
* (UserCollectionService)
*/
export const USER_COLL_SHORT_TITLE = 'user_coll/short_title' as const;
/**
* User Collection could not be found
* (UserCollectionService)
*/
export const USER_COLL_NOT_FOUND = 'user_coll/not_found' as const;
/**
* UserCollection is already a root collection
* (UserCollectionService)
*/
export const USER_COL_ALREADY_ROOT =
'user_coll/target_user_collection_is_already_root_user_collection' as const;
/**
* Target and Parent user collections are the same
* (UserCollectionService)
*/
export const USER_COLL_DEST_SAME =
'user_coll/target_and_destination_user_collection_are_same' as const;
/**
* Target and Parent user collections are not from the same user
* (UserCollectionService)
*/
export const USER_COLL_NOT_SAME_USER = 'user_coll/not_same_user' as const;
/**
* Target and Parent user collections are not from the same type
* (UserCollectionService)
*/
export const USER_COLL_NOT_SAME_TYPE = 'user_coll/type_mismatch' as const;
/**
* Cannot make a parent user collection a child of itself
* (UserCollectionService)
*/
export const USER_COLL_IS_PARENT_COLL =
'user_coll/user_collection_is_parent_coll' as const;
/**
* User Collection Re-Ordering Failed
* (UserCollectionService)
*/
export const USER_COLL_REORDERING_FAILED =
'user_coll/reordering_failed' as const;
/**
* The Collection and Next User Collection are the same
* (UserCollectionService)
*/
export const USER_COLL_SAME_NEXT_COLL =
'user_coll/user_collection_and_next_user_collection_are_same' as const;
/**
* The User Collection does not belong to the logged-in user
* (UserCollectionService)
*/
export const USER_NOT_OWNER = 'user_coll/user_not_owner' as const;

View File

@@ -7,6 +7,8 @@ import { TeamEnvironment } from 'src/team-environments/team-environments.model';
import { TeamCollection } from 'src/team-collection/team-collection.model';
import { TeamRequest } from 'src/team-request/team-request.model';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
import { UserCollection } from '@prisma/client';
import { UserCollectionReorderData } from 'src/user-collection/user-collections.model';
import { Shortcode } from 'src/shortcode/shortcode.model';
// A custom message type that defines the topic and the corresponding payload.
@@ -21,6 +23,11 @@ export type TopicDef = {
[
topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}`
]: UserHistory;
[
topic: `user_coll/${string}/${'created' | 'updated' | 'moved'}`
]: UserCollection;
[topic: `user_coll/${string}/${'deleted'}`]: string;
[topic: `user_coll/${string}/${'order_updated'}`]: UserCollectionReorderData;
[topic: `team/${string}/member_removed`]: string;
[topic: `team/${string}/${'member_added' | 'member_updated'}`]: TeamMember;
[

View File

@@ -36,7 +36,7 @@ export class TeamCollectionResolver {
@ResolveField(() => TeamCollection, {
description:
'The collection whom is the parent of this collection (null if this is root collection)',
'The collection who is the parent of this collection (null if this is root collection)',
nullable: true,
complexity: 3,
})

View File

@@ -0,0 +1,4 @@
export enum ReqType {
REST = 'REST',
GQL = 'GQL',
}

View File

@@ -0,0 +1,75 @@
import { Field, ID, ArgsType } from '@nestjs/graphql';
import { PaginationArgs } from 'src/types/input-types.args';
@ArgsType()
export class CreateRootUserCollectionArgs {
@Field({ name: 'title', description: 'Title of the new user collection' })
title: string;
}
@ArgsType()
export class CreateChildUserCollectionArgs {
@Field({ name: 'title', description: 'Title of the new user collection' })
title: string;
@Field(() => ID, {
name: 'parentUserCollectionID',
description: 'ID of the parent to the new user collection',
})
parentUserCollectionID: string;
}
@ArgsType()
export class GetUserChildCollectionArgs extends PaginationArgs {
@Field(() => ID, {
name: 'userCollectionID',
description: 'ID of the parent to the user collection',
})
userCollectionID: string;
}
@ArgsType()
export class RenameUserCollectionsArgs {
@Field(() => ID, {
name: 'userCollectionID',
description: 'ID of the user collection',
})
userCollectionID: string;
@Field({
name: 'newTitle',
description: 'The updated title of the user collection',
})
newTitle: string;
}
@ArgsType()
export class UpdateUserCollectionArgs {
@Field(() => ID, {
name: 'collectionID',
description: 'ID of collection being moved',
})
collectionID: string;
@Field(() => ID, {
name: 'nextCollectionID',
nullable: true,
description: 'ID of collection being moved',
})
nextCollectionID: string;
}
@ArgsType()
export class MoveUserCollectionArgs {
@Field(() => ID, {
name: 'destCollectionID',
description: 'ID of the parent to the new collection',
nullable: true,
})
destCollectionID: string;
@Field(() => ID, {
name: 'userCollectionID',
description: 'ID of the collection',
})
userCollectionID: string;
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { UserCollectionService } from './user-collection.service';
import { UserCollectionResolver } from './user-collection.resolver';
import { PrismaModule } from 'src/prisma/prisma.module';
import { UserModule } from 'src/user/user.module';
import { PubSubModule } from 'src/pubsub/pubsub.module';
@Module({
imports: [PrismaModule, UserModule, PubSubModule],
providers: [UserCollectionService, UserCollectionResolver],
})
export class UserCollectionModule {}

View File

@@ -0,0 +1,348 @@
import { UseGuards } from '@nestjs/common';
import {
Resolver,
Mutation,
Args,
ID,
Query,
ResolveField,
Parent,
Subscription,
} from '@nestjs/graphql';
import { GqlUser } from 'src/decorators/gql-user.decorator';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { AuthUser } from 'src/types/AuthUser';
import { UserCollectionService } from './user-collection.service';
import {
UserCollection,
UserCollectionReorderData,
} from './user-collections.model';
import * as E from 'fp-ts/Either';
import { throwErr } from 'src/utils';
import { User } from 'src/user/user.model';
import { PaginationArgs } from 'src/types/input-types.args';
import {
CreateChildUserCollectionArgs,
CreateRootUserCollectionArgs,
MoveUserCollectionArgs,
RenameUserCollectionsArgs,
UpdateUserCollectionArgs,
} from './input-type.args';
import { ReqType } from 'src/types/RequestTypes';
@Resolver(() => UserCollection)
export class UserCollectionResolver {
constructor(
private readonly userCollectionService: UserCollectionService,
private readonly pubSub: PubSubService,
) {}
// Field Resolvers
@ResolveField(() => User, {
description: 'User the collection belongs to',
})
async user(@GqlUser() user: AuthUser) {
return user;
}
@ResolveField(() => UserCollection, {
description: 'Parent user collection (null if root)',
nullable: true,
})
async parent(@Parent() collection: UserCollection) {
return this.userCollectionService.getParentOfUserCollection(collection.id);
}
@ResolveField(() => [UserCollection], {
description: 'List of children REST user collection',
complexity: 3,
})
childrenREST(
@Parent() collection: UserCollection,
@Args() args: PaginationArgs,
) {
return this.userCollectionService.getChildrenOfUserCollection(
collection.id,
args.cursor,
args.take,
ReqType.REST,
);
}
@ResolveField(() => [UserCollection], {
description: 'List of children GraphQL user collection',
complexity: 3,
})
childrenGQL(
@Parent() collection: UserCollection,
@Args() args: PaginationArgs,
) {
return this.userCollectionService.getChildrenOfUserCollection(
collection.id,
args.cursor,
args.take,
ReqType.GQL,
);
}
// Queries
@Query(() => [UserCollection], {
description: 'Get the root REST user collections for a user',
})
@UseGuards(GqlAuthGuard)
rootRESTUserCollections(
@GqlUser() user: AuthUser,
@Args() args: PaginationArgs,
) {
return this.userCollectionService.getUserRootCollections(
user,
args.cursor,
args.take,
ReqType.REST,
);
}
@Query(() => [UserCollection], {
description: 'Get the root GraphQL user collections for a user',
})
@UseGuards(GqlAuthGuard)
rootGQLUserCollections(
@GqlUser() user: AuthUser,
@Args() args: PaginationArgs,
) {
return this.userCollectionService.getUserRootCollections(
user,
args.cursor,
args.take,
ReqType.GQL,
);
}
@Query(() => UserCollection, {
description: 'Get user collection with ID',
})
@UseGuards(GqlAuthGuard)
async userCollection(
@Args({
type: () => ID,
name: 'userCollectionID',
description: 'ID of the user collection',
})
userCollectionID: string,
) {
const userCollection = await this.userCollectionService.getUserCollection(
userCollectionID,
);
if (E.isLeft(userCollection)) throwErr(userCollection.left);
return userCollection.right;
}
// Mutations
@Mutation(() => UserCollection, {
description: 'Creates root REST user collection(no parent user collection)',
})
@UseGuards(GqlAuthGuard)
async createRESTRootUserCollection(
@GqlUser() user: AuthUser,
@Args() args: CreateRootUserCollectionArgs,
) {
const userCollection =
await this.userCollectionService.createUserCollection(
user,
args.title,
null,
ReqType.REST,
);
if (E.isLeft(userCollection)) throwErr(userCollection.left);
return userCollection.right;
}
@Mutation(() => UserCollection, {
description:
'Creates root GraphQL user collection(no parent user collection)',
})
@UseGuards(GqlAuthGuard)
async createGQLRootUserCollection(
@GqlUser() user: AuthUser,
@Args() args: CreateRootUserCollectionArgs,
) {
const userCollection =
await this.userCollectionService.createUserCollection(
user,
args.title,
null,
ReqType.GQL,
);
if (E.isLeft(userCollection)) throwErr(userCollection.left);
return userCollection.right;
}
@Mutation(() => UserCollection, {
description: 'Creates a new child REST user collection',
})
@UseGuards(GqlAuthGuard)
async createGQLChildUserCollection(
@GqlUser() user: AuthUser,
@Args() args: CreateChildUserCollectionArgs,
) {
const userCollection =
await this.userCollectionService.createUserCollection(
user,
args.title,
args.parentUserCollectionID,
ReqType.GQL,
);
if (E.isLeft(userCollection)) throwErr(userCollection.left);
return userCollection.right;
}
@Mutation(() => UserCollection, {
description: 'Creates a new child GraphQL user collection',
})
@UseGuards(GqlAuthGuard)
async createRESTChildUserCollection(
@GqlUser() user: AuthUser,
@Args() args: CreateChildUserCollectionArgs,
) {
const userCollection =
await this.userCollectionService.createUserCollection(
user,
args.title,
args.parentUserCollectionID,
ReqType.REST,
);
if (E.isLeft(userCollection)) throwErr(userCollection.left);
return userCollection.right;
}
@Mutation(() => UserCollection, {
description: 'Rename a user collection',
})
@UseGuards(GqlAuthGuard)
async renameUserCollection(
@GqlUser() user: AuthUser,
@Args() args: RenameUserCollectionsArgs,
) {
const updatedUserCollection =
await this.userCollectionService.renameCollection(
args.newTitle,
args.userCollectionID,
user.uid,
);
if (E.isLeft(updatedUserCollection)) throwErr(updatedUserCollection.left);
return updatedUserCollection.right;
}
@Mutation(() => Boolean, {
description: 'Delete a user collection',
})
@UseGuards(GqlAuthGuard)
async deleteUserCollection(
@Args({
name: 'userCollectionID',
description: 'ID of the user collection',
type: () => ID,
})
userCollectionID: string,
@GqlUser() user: AuthUser,
) {
const result = await this.userCollectionService.deleteUserCollection(
userCollectionID,
user.uid,
);
if (E.isLeft(result)) throwErr(result.left);
return result.right;
}
@Mutation(() => UserCollection, {
description: 'Move user collection into new parent or root',
})
@UseGuards(GqlAuthGuard)
async moveUserCollection(
@Args() args: MoveUserCollectionArgs,
@GqlUser() user: AuthUser,
) {
const res = await this.userCollectionService.moveUserCollection(
args.userCollectionID,
args.destCollectionID,
user.uid,
);
if (E.isLeft(res)) {
throwErr(res.left);
}
return res.right;
}
@Mutation(() => Boolean, {
description: 'Move user collection into new parent or root',
})
@UseGuards(GqlAuthGuard)
async updateUserCollectionOrder(
@Args() args: UpdateUserCollectionArgs,
@GqlUser() user: AuthUser,
) {
const res = await this.userCollectionService.updateUserCollectionOrder(
args.collectionID,
args.nextCollectionID,
user.uid,
);
if (E.isLeft(res)) {
throwErr(res.left);
}
return res.right;
}
// Subscriptions
@Subscription(() => UserCollection, {
description: 'Listen for User Collection Creation',
resolve: (value) => value,
})
@UseGuards(GqlAuthGuard)
userCollectionCreated(@GqlUser() user: AuthUser) {
return this.pubSub.asyncIterator(`user_coll/${user.uid}/created`);
}
@Subscription(() => UserCollection, {
description: 'Listen to when a User Collection has been updated.',
resolve: (value) => value,
})
@UseGuards(GqlAuthGuard)
userCollectionUpdated(@GqlUser() user: AuthUser) {
return this.pubSub.asyncIterator(`user_coll/${user.uid}/updated`);
}
@Subscription(() => ID, {
description: 'Listen to when a User Collection has been deleted',
resolve: (value) => value,
})
@UseGuards(GqlAuthGuard)
userCollectionRemoved(@GqlUser() user: AuthUser) {
return this.pubSub.asyncIterator(`user_coll/${user.uid}/deleted`);
}
@Subscription(() => UserCollection, {
description: 'Listen to when a User Collection has been moved',
resolve: (value) => value,
})
@UseGuards(GqlAuthGuard)
userCollectionMoved(@GqlUser() user: AuthUser) {
return this.pubSub.asyncIterator(`user_coll/${user.uid}/moved`);
}
@Subscription(() => UserCollectionReorderData, {
description: 'Listen to when a User Collections position has changed',
resolve: (value) => value,
})
@UseGuards(GqlAuthGuard)
userCollectionOrderUpdated(@GqlUser() user: AuthUser) {
return this.pubSub.asyncIterator(`user_coll/${user.uid}/order_updated`);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,616 @@
import { Injectable } from '@nestjs/common';
import {
USER_COLL_DEST_SAME,
USER_COLL_IS_PARENT_COLL,
USER_COLL_NOT_FOUND,
USER_COLL_NOT_SAME_TYPE,
USER_COLL_NOT_SAME_USER,
USER_COLL_REORDERING_FAILED,
USER_COLL_SAME_NEXT_COLL,
USER_COLL_SHORT_TITLE,
USER_COL_ALREADY_ROOT,
USER_NOT_FOUND,
USER_NOT_OWNER,
} from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service';
import { AuthUser } from 'src/types/AuthUser';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { Prisma, User, UserCollection } from '@prisma/client';
import { UserCollection as UserCollectionModel } from './user-collections.model';
import { ReqType } from 'src/types/RequestTypes';
import { isValidLength } from 'src/utils';
@Injectable()
export class UserCollectionService {
constructor(
private readonly prisma: PrismaService,
private readonly pubsub: PubSubService,
) {}
private cast(collection: UserCollection) {
return <UserCollectionModel>{
...collection,
userID: collection.userUid,
};
}
private async getChildCollectionsCount(collectionID: string) {
const childCollectionCount = await this.prisma.userCollection.findMany({
where: { parentID: collectionID },
orderBy: {
orderIndex: 'desc',
},
});
if (!childCollectionCount.length) return 0;
return childCollectionCount[0].orderIndex;
}
private async getRootCollectionsCount(userID: string) {
const rootCollectionCount = await this.prisma.userCollection.findMany({
where: { userUid: userID, parentID: null },
orderBy: {
orderIndex: 'desc',
},
});
if (!rootCollectionCount.length) return 0;
return rootCollectionCount[0].orderIndex;
}
private async isOwnerCheck(collectionID: string, userID: string) {
try {
await this.prisma.userCollection.findFirstOrThrow({
where: {
id: collectionID,
userUid: userID,
},
});
return O.some(true);
} catch (error) {
return O.none;
}
}
async getUserOfCollection(collectionID: string) {
try {
const userCollection = await this.prisma.userCollection.findUniqueOrThrow(
{
where: {
id: collectionID,
},
include: {
user: true,
},
},
);
return E.right(userCollection.user);
} catch (error) {
return E.left(USER_NOT_FOUND);
}
}
async getParentOfUserCollection(collectionID: string) {
const { parent } = await this.prisma.userCollection.findUnique({
where: {
id: collectionID,
},
include: {
parent: true,
},
});
return parent;
}
async getChildrenOfUserCollection(
collectionID: string,
cursor: string | null,
take: number,
type: ReqType,
) {
return this.prisma.userCollection.findMany({
where: {
parentID: collectionID,
type: type,
},
orderBy: {
orderIndex: 'asc',
},
take: take, // default: 10
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
});
}
async getUserCollection(collectionID: string) {
try {
const userCollection = await this.prisma.userCollection.findUniqueOrThrow(
{
where: {
id: collectionID,
},
},
);
return E.right(userCollection);
} catch (error) {
return E.left(USER_COLL_NOT_FOUND);
}
}
async createUserCollection(
user: AuthUser,
title: string,
parentUserCollectionID: string | null,
type: ReqType,
) {
const isTitleValid = isValidLength(title, 3);
if (!isTitleValid) return E.left(USER_COLL_SHORT_TITLE);
if (parentUserCollectionID !== null) {
const isOwner = await this.isOwnerCheck(parentUserCollectionID, user.uid);
if (O.isNone(isOwner)) return E.left(USER_NOT_OWNER);
}
const isParent = parentUserCollectionID
? {
connect: {
id: parentUserCollectionID,
},
}
: undefined;
const userCollection = await this.prisma.userCollection.create({
data: {
title: title,
type: type,
user: {
connect: {
uid: user.uid,
},
},
parent: isParent,
orderIndex: !parentUserCollectionID
? (await this.getRootCollectionsCount(user.uid)) + 1
: (await this.getChildCollectionsCount(parentUserCollectionID)) + 1,
},
});
await this.pubsub.publish(`user_coll/${user.uid}/created`, userCollection);
return E.right(userCollection);
}
async getUserRootCollections(
user: AuthUser,
cursor: string | null,
take: number,
type: ReqType,
) {
return this.prisma.userCollection.findMany({
where: {
userUid: user.uid,
parentID: null,
type: type,
},
orderBy: {
orderIndex: 'asc',
},
take: take, // default: 10
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
});
}
async getUserChildCollections(
user: AuthUser,
userCollectionID: string,
cursor: string | null,
take: number,
type: ReqType,
) {
return this.prisma.userCollection.findMany({
where: {
userUid: user.uid,
parentID: userCollectionID,
type: type,
},
take: take, // default: 10
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
});
}
async renameCollection(
newTitle: string,
userCollectionID: string,
userID: string,
) {
const isTitleValid = isValidLength(newTitle, 3);
if (!isTitleValid) return E.left(USER_COLL_SHORT_TITLE);
// Check to see is the collection belongs to the user
const isOwner = await this.isOwnerCheck(userCollectionID, userID);
if (O.isNone(isOwner)) return E.left(USER_NOT_OWNER);
try {
const updatedUserCollection = await this.prisma.userCollection.update({
where: {
id: userCollectionID,
},
data: {
title: newTitle,
},
});
this.pubsub.publish(
`user_coll/${updatedUserCollection.userUid}/updated`,
updatedUserCollection,
);
return E.right(updatedUserCollection);
} catch (error) {
return E.left(USER_COLL_NOT_FOUND);
}
}
private async removeUserCollection(collectionID: string) {
try {
const deletedUserCollection = await this.prisma.userCollection.delete({
where: {
id: collectionID,
},
});
return E.right(deletedUserCollection);
} catch (error) {
return E.left(USER_COLL_NOT_FOUND);
}
}
private async deleteCollectionData(collection: UserCollection) {
// Get all child collections in collectionID
const childCollectionList = await this.prisma.userCollection.findMany({
where: {
parentID: collection.id,
},
});
// Delete child collections
await Promise.all(
childCollectionList.map((coll) =>
this.deleteUserCollection(coll.id, coll.userUid),
),
);
// Delete all requests in collectionID
await this.prisma.userRequest.deleteMany({
where: {
collectionID: collection.id,
},
});
// Update orderIndexes in userCollection table for user
await this.updateOrderIndex(
collection.parentID,
{ gt: collection.orderIndex },
{ decrement: 1 },
);
// Delete collection from UserCollection table
const deletedUserCollection = await this.removeUserCollection(
collection.id,
);
if (E.isLeft(deletedUserCollection))
return E.left(deletedUserCollection.left);
this.pubsub.publish(
`user_coll/${deletedUserCollection.right.userUid}/deleted`,
deletedUserCollection.right.id,
);
return E.right(true);
}
async deleteUserCollection(collectionID: string, userID: string) {
// Get collection details of collectionID
const collection = await this.getUserCollection(collectionID);
if (E.isLeft(collection)) return E.left(USER_COLL_NOT_FOUND);
// Check to see is the collection belongs to the user
if (collection.right.userUid !== userID) return E.left(USER_NOT_OWNER);
// Delete all child collections and requests in the collection
const collectionData = await this.deleteCollectionData(collection.right);
if (E.isLeft(collectionData)) return E.left(collectionData.left);
return E.right(true);
}
private async changeParent(
collection: UserCollection,
parentCollectionID: string | null,
) {
try {
let collectionCount: number;
if (!parentCollectionID)
collectionCount = await this.getRootCollectionsCount(
collection.userUid,
);
collectionCount = await this.getChildCollectionsCount(parentCollectionID);
const updatedCollection = await this.prisma.userCollection.update({
where: {
id: collection.id,
},
data: {
// if parentCollectionID == null, collection becomes root collection
// if parentCollectionID != null, collection becomes child collection
parentID: parentCollectionID,
orderIndex: collectionCount + 1,
},
});
return E.right(updatedCollection);
} catch (error) {
return E.left(USER_COLL_NOT_FOUND);
}
}
private async isParent(
collection: UserCollection,
destCollection: UserCollection,
): Promise<O.Option<boolean>> {
// Check if collection and destCollection are same
if (collection === destCollection) {
return O.none;
}
if (destCollection.parentID !== null) {
// Check if ID of collection is same as parent of destCollection
if (destCollection.parentID === collection.id) {
return O.none;
}
// Get collection details of collection one step above in the tree i.e the parent collection
const parentCollection = await this.getUserCollection(
destCollection.parentID,
);
if (E.isLeft(parentCollection)) {
return O.none;
}
// Call isParent again now with parent collection
return await this.isParent(collection, parentCollection.right);
} else {
return O.some(true);
}
}
private async updateOrderIndex(
parentID: string,
orderIndexCondition: Prisma.IntFilter,
dataCondition: Prisma.IntFieldUpdateOperationsInput,
) {
const updatedUserCollection = await this.prisma.userCollection.updateMany({
where: {
parentID: parentID,
orderIndex: orderIndexCondition,
},
data: { orderIndex: dataCondition },
});
return updatedUserCollection;
}
async moveUserCollection(
userCollectionID: string,
destCollectionID: string | null,
userID: string,
) {
// Get collection details of collectionID
const collection = await this.getUserCollection(userCollectionID);
if (E.isLeft(collection)) return E.left(USER_COLL_NOT_FOUND);
// Check to see is the collection belongs to the user
if (collection.right.userUid !== userID) return E.left(USER_NOT_OWNER);
// destCollectionID == null i.e move collection to root
if (!destCollectionID) {
if (!collection.right.parentID) {
// collection is a root collection
// Throw error if collection is already a root collection
return E.left(USER_COL_ALREADY_ROOT);
}
// Move child collection into root and update orderIndexes for root userCollections
await this.updateOrderIndex(
collection.right.parentID,
{ gt: collection.right.orderIndex },
{ decrement: 1 },
);
// Change parent from child to root i.e child collection becomes a root collection
const updatedCollection = await this.changeParent(collection.right, null);
if (E.isLeft(updatedCollection)) return E.left(updatedCollection.left);
this.pubsub.publish(
`user_coll/${collection.right.userUid}/moved`,
updatedCollection.right,
);
return E.right(updatedCollection.right);
}
// destCollectionID != null i.e move into another collection
if (userCollectionID === destCollectionID) {
// Throw error if collectionID and destCollectionID are the same
return E.left(USER_COLL_DEST_SAME);
}
// Get collection details of destCollectionID
const destCollection = await this.getUserCollection(destCollectionID);
if (E.isLeft(destCollection)) return E.left(USER_COLL_NOT_FOUND);
// Check if collection and destCollection belong to the same collection type
if (collection.right.type !== destCollection.right.type) {
return E.left(USER_COLL_NOT_SAME_TYPE);
}
// Check if collection and destCollection belong to the same user account
if (collection.right.userUid !== destCollection.right.userUid) {
return E.left(USER_COLL_NOT_SAME_USER);
}
// Check if collection is present on the parent tree for destCollection
const checkIfParent = await this.isParent(
collection.right,
destCollection.right,
);
if (O.isNone(checkIfParent)) {
return E.left(USER_COLL_IS_PARENT_COLL);
}
// Move root/child collection into another child collection and update orderIndexes of the previous parent
await this.updateOrderIndex(
collection.right.parentID,
{ gt: collection.right.orderIndex },
{ decrement: 1 },
);
// Change parent from null to teamCollection i.e collection becomes a child collection
const updatedCollection = await this.changeParent(
collection.right,
destCollection.right.id,
);
if (E.isLeft(updatedCollection)) return E.left(updatedCollection.left);
this.pubsub.publish(
`user_coll/${collection.right.userUid}/moved`,
updatedCollection.right,
);
return E.right(updatedCollection.right);
}
getCollectionCount(collectionID: string): Promise<number> {
return this.prisma.userCollection.count({
where: { parentID: collectionID },
});
}
async updateUserCollectionOrder(
collectionID: string,
nextCollectionID: string | null,
userID: string,
) {
// Throw error if collectionID and nextCollectionID are the same
if (collectionID === nextCollectionID)
return E.left(USER_COLL_SAME_NEXT_COLL);
// Get collection details of collectionID
const collection = await this.getUserCollection(collectionID);
if (E.isLeft(collection)) return E.left(USER_COLL_NOT_FOUND);
// Check to see is the collection belongs to the user
if (collection.right.userUid !== userID) return E.left(USER_NOT_OWNER);
if (!nextCollectionID) {
// nextCollectionID == null i.e move collection to the end of the list
try {
await this.prisma.$transaction(async (tx) => {
// Step 1: Decrement orderIndex of all items that come after collection.orderIndex till end of list of items
await tx.userCollection.updateMany({
where: {
parentID: collection.right.parentID,
orderIndex: {
gte: collection.right.orderIndex + 1,
},
},
data: {
orderIndex: { decrement: 1 },
},
});
// Step 2: Update orderIndex of collection to length of list
const updatedUserCollection = await tx.userCollection.update({
where: { id: collection.right.id },
data: {
orderIndex: await this.getCollectionCount(
collection.right.parentID,
),
},
});
});
this.pubsub.publish(
`user_coll/${collection.right.userUid}/order_updated`,
{
userCollection: this.cast(collection.right),
nextUserCollection: null,
},
);
return E.right(true);
} catch (error) {
return E.left(USER_COLL_REORDERING_FAILED);
}
}
// nextCollectionID != null i.e move to a certain position
// Get collection details of nextCollectionID
const subsequentCollection = await this.getUserCollection(nextCollectionID);
if (E.isLeft(subsequentCollection)) return E.left(USER_COLL_NOT_FOUND);
if (collection.right.userUid !== subsequentCollection.right.userUid)
return E.left(USER_COLL_NOT_SAME_USER);
// Check if collection and subsequentCollection belong to the same collection type
if (collection.right.type !== subsequentCollection.right.type) {
return E.left(USER_COLL_NOT_SAME_TYPE);
}
try {
await this.prisma.$transaction(async (tx) => {
// Step 1: Determine if we are moving collection up or down the list
const isMovingUp =
subsequentCollection.right.orderIndex < collection.right.orderIndex;
// Step 2: Update OrderIndex of items in list depending on moving up or down
const updateFrom = isMovingUp
? subsequentCollection.right.orderIndex
: collection.right.orderIndex + 1;
const updateTo = isMovingUp
? collection.right.orderIndex - 1
: subsequentCollection.right.orderIndex - 1;
await tx.userCollection.updateMany({
where: {
parentID: collection.right.parentID,
orderIndex: { gte: updateFrom, lte: updateTo },
},
data: {
orderIndex: isMovingUp ? { increment: 1 } : { decrement: 1 },
},
});
// Step 3: Update OrderIndex of collection
const updatedUserCollection = await tx.userCollection.update({
where: { id: collection.right.id },
data: {
orderIndex: isMovingUp
? subsequentCollection.right.orderIndex
: subsequentCollection.right.orderIndex - 1,
},
});
});
this.pubsub.publish(
`user_coll/${collection.right.userUid}/order_updated`,
{
userCollection: this.cast(collection.right),
nextUserCollection: this.cast(subsequentCollection.right),
},
);
return E.right(true);
} catch (error) {
return E.left(USER_COLL_REORDERING_FAILED);
}
}
}

View File

@@ -0,0 +1,43 @@
import { ObjectType, Field, ID, registerEnumType } from '@nestjs/graphql';
import { ReqType } from 'src/types/RequestTypes';
@ObjectType()
export class UserCollection {
@Field(() => ID, {
description: 'ID of the user collection',
})
id: string;
@Field({
description: 'Displayed title of the user collection',
})
title: string;
@Field(() => ReqType, {
description: 'Type of the user collection',
})
type: ReqType;
parentID: string | null;
userID: string;
}
@ObjectType()
export class UserCollectionReorderData {
@Field({
description: 'User Collection being moved',
})
userCollection: UserCollection;
@Field({
description:
'User Collection succeeding the collection being moved in its new position',
nullable: true,
})
nextUserCollection?: UserCollection;
}
registerEnumType(ReqType, {
name: 'CollType',
});

View File

@@ -8,6 +8,7 @@ import * as T from 'fp-ts/Task';
import * as E from 'fp-ts/Either';
import * as A from 'fp-ts/Array';
import { TeamMemberRole } from './team/team.model';
import { User } from './user/user.model';
import { JSON_INVALID } from './errors';
/**
@@ -124,7 +125,7 @@ export const validateEmail = (email: string) => {
).test(email);
};
/*
/**
* String to JSON parser
* @param {str} str The string to parse
* @returns {E.Right<T> | E.Left<"json_invalid">} An Either of the parsed JSON
@@ -138,3 +139,16 @@ export function stringToJson<T>(
return E.left(JSON_INVALID);
}
}
/**
*
* @param title string whose length we need to check
* @param length minimum length the title needs to be
* @returns boolean if title is of valid length or not
*/
export function isValidLength(title: string, length: number) {
if (title.length < length) {
return false;
}
return true;
}