-
Notifications
You must be signed in to change notification settings - Fork 11
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
Add reef admin relationships #128
Conversation
@avalmas-programize can you list the necessary frontend changes? Or even have a stab at them? They shouldn't be too complicated at the moment. |
@@ -44,17 +43,6 @@ export class ReefsController { | |||
return this.reefsService.findDailyData(id); | |||
} | |||
|
|||
@Get(':id/surveys/:poi') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this not needed anymore?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As I see it, this endpoint was using a mock response which is already implemented in the survey details endpoint (GET /reefs/:reef_id/surveys/:id). The only difference was that I fetch all POIs of a survey and this was designed to fetch only one POI of a survey. I don't see any use for it anymore.
Also it was conflicting with the new endpoints (surveys endpoints with the updated prefix)
Front-end changes:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some suggested clean-ups throughout, but the big thing is re-doing the schema & entities a bit...
return !!isReefAdmin; | ||
} | ||
|
||
return true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really want to return true
in this case? The only way to reach this point is if reefId
is null/undefined, which (probably) means the request was malformed. It seems to me like a safer course of action in that case is to return false
...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm I thought it from a different angle, but I see your point. I will return false by default.
ManyToOne, | ||
Unique, | ||
} from 'typeorm'; | ||
// eslint-disable-next-line import/no-cycle |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't disable the linter here, just import the type:
import type { Reef } from './reefs.entity';
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I removed all the lint disables from all the entities.
However the relations decorator expects a entity returned to use as a type for the relation.
To bypass this I had to change the way we declare a relationship from:
@ManyToOne(() => Reef, (reef) => reef.reefToAdmin, { cascade: true })
reef: Reef;
to
@ManyToOne<Reef>('Reef', (reef) => reef.reefToAdmin, { cascade: true })
reef: Reef;
But this does not solve all of the issues. After compiling the code an error appears in the dist files:
/home/antonios/Documents/Programize/aqualink-app-backend/packages/api/dist/src/reefs/reef-to-admin.entity.js
__metadata("design:type", User)
ReferenceError: User is not defined
To solve this we have to create an interface for each entity and reference this as the type of the class member
typeorm/typeorm#4190
Are we okay with this refactor?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I see the issue, we need to actually use the types as a return value, not just a type annotation.
New plan: Since circular dependencies are pretty much baked into how TypeORM does many-to-many relations, let's add a new ESlint rule override to disable that check in *.entity.ts
files only.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay
|
||
@Entity() | ||
@Unique('unique-relationship', ['reefId', 'adminId']) | ||
export class ReefToAdmin { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You shouldn't need to define a TypeORM entity at all for a join table unless you're trying to store custom information on the join table (e.g. an attribute that describes a condition on the join). In this case you're just trying to set up a many-to-many join between two entities, so defining it in this way has numerous disadvantages:
- This is a class based entity that TypeORM will actually try to instantiate if you load records in some cases, increasing the overhead.
- You have a completely extraneous extra column (
reefToAdminId
) that conveys no extra information and requires a hackyunique
constraint (the "right" way to set up a table like this would just use thereefId, adminId
as the primary key. - This entity forces you to specify a naming convention (which in this case you didn't use the "standard" SQL naming convention for join tables across most web frameworks which would be
reefs_admins
) rather than relying on predictable & standard defaults.
See the docs on Many-To-Many in TypeORM.
This is what you want to do:
class Reef {
// Other attributes
@ManyToMany(() => User, (user) => user.administered_reefs)
@JoinTable()
admins: User[]
}
class User {
// Other attributes
@ManyToMany(() => Reef, (reef) => reef.admins)
administered_reefs: Reef[]
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wanted to add a custom join table in case we need it in the future.
I will revert my change
} | ||
|
||
if (reefId) { | ||
const isReefAdmin = await this.reefToAdminRepository.findOne({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have an adminLevel
that corresponds to a reef manager, do we want to enforce that that admin level is set as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The routes this guard is added are the already protected ones, i.e the ones that do not allow the Default user to access them, only ReefManager and SuperAdmin.
In the first lines of this guard I bypass the guard if the level of the user is SuperAdmin.
So in this we are left with the case that the user is a ReefManager.
Also if in the future this is added to a not protected route, i.e a default user is tested to pass this check they will fail because the are not a manager of the reef, which makes perfect sense.
We should not allow a default user access more than a reef manager.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That may be true in the current use-case - wherever we use this is already a protected route, but it would be fairly easy down the line for someone not to realize that. That's why I was suggesting adding an adminLevel
check inside this guard so that you don't have to ensure that a completely independent guard is also set whenever you use it.
// eslint-disable-next-line import/no-cycle | ||
import { DailyData } from './daily-data.entity'; | ||
import { VideoStream } from './video-streams.entity'; | ||
// eslint-disable-next-line import/no-cycle | ||
import { Survey } from '../surveys/surveys.entity'; | ||
// eslint-disable-next-line import/no-cycle | ||
import { ReefToAdmin } from './reef-to-admin.entity'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
import type { ReefToAdmin }
(the other no cycle imports should use this as well - get rid of all the linter disable comments)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like I said this is more complicated than it seems.
export class SurveysController { | ||
constructor(private surveyService: SurveysService) {} | ||
|
||
@UseGuards(IsReefAdminGuard) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than requiring us to specify this guard on every single handler, it might be worth adding a @Public
decorator that does setMetadata("isPublic", true)
and then look up the metadata on the handler inside the isReefAdminGuard
function and only lets the request through without the check if that metadata is set. Then you could specify the guard on a controller-wise level here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting approach.
However I would name the decorator something more specific like @ManagersOnly
because you would assume a @Public
decorator to also have an effect to the @Auth
decorator.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmmm. IsReefAdminGuard needs the Auth decorator to run first and since the first one is now a Controller decorator it will always run first. So we need a different approach if we want to implement this.
We could edit the Auth decorator in order to dynamically add the IsReefAdminGuard to the list of decorators used or have the new decorator (the Public one you suggested) affect both guards' behavior and add those as global or controller decorators
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
have the new decorator (the Public one you suggested) affect both guards' behavior and add those as global or controller decorators
This is what I was imagining. If you move IsReefAdminGuard
to the controller level, you definitely have to move the Auth guard as well.
@@ -10,6 +10,7 @@ import { | |||
} from 'typeorm'; | |||
// eslint-disable-next-line import/no-cycle | |||
import { Reef } from '../reefs/reefs.entity'; | |||
// eslint-disable-next-line import/no-cycle |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
again, use import type { ... }
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like I said this is more complicated than it seems.
} from 'typeorm'; | ||
import { Exclude } from 'class-transformer'; | ||
// eslint-disable-next-line import/no-cycle | ||
import { ReefToAdmin } from '../reefs/reef-to-admin.entity'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
another import type { }
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like I said this is more complicated than it seems.
|
||
// If relationship already exists, skip | ||
if (relationshipExists) { | ||
return Promise.resolve(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is already an sync function, you don't need to return anything at all to get it to wrap in a Promise
(or you could return true
if that feels better)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was not letting me to just write return;
because map expects something returned so I thought I could return an empty Promise since that's what I want.
But I will change it to null since I agree it is a bit ugly.
83d2797
to
9ccda33
Compare
427730a
to
a29e271
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Getting close!
A few small cleanups, then:
- for the cyclic dependencies, let's just leave them in the entity code, but add an eslint rule override for
*.entity.ts
files for that specific rule so that we don't need line-by-line overrides. - For the
@Public
decorator - I think you'd need to have it affect the auth guard too, then it should work. I don't feel super strongly about it either way, but I generally think that it might be a cleaner way to define a controller where almost all the routes are protected but there are 1 or 2 public ones... if it's taking too long don't worry about it though.
@Get('current/administrated-reefs') | ||
getAdministratedReefs(@Req() req: AuthRequest): Promise<Reef[]> { | ||
return this.usersService.getAdministratedReefs(req); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's call these "administered" (not "administrated") (ref)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops typo. Fixed
// Add new user as admin | ||
return this.reefRepository.update( | ||
{ | ||
id: reef.id, | ||
}, | ||
{ | ||
admins: [...reef.admins, user], | ||
}, | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we add this relation from the "other side" (e.g. using the userRepository
) instead? I think this is the only dependency the UserService has on the reefRepository
, so if we could add it from the "other" side of the relation we could drop that dependency from this service...
this.usersRepository.update({ id: user.id }, { administeredReefs: ... })
Maybe?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
relations: ['reef', 'reef.admins'], | ||
}); | ||
|
||
const reefEntities: Promise<UpdateResult | null>[] = reefAssociations.map( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Implicitly, a user who has user reef admin associations should also get the reef manager role, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I thought it would have been set from the application. Done.
f2fdf7f
to
7b74822
Compare
I have added the Public decorator. Implementation:
|
Change controller's decorators structure Add public decorator and override behavior for both auth and is-reef-admin decorators Add override-level-access to override the default decorator access for some endpoints
b210757
to
c33281b
Compare
When I tried it, I did not get promoted to "reef_manager" in the DB for some reason. But I am actually wondering if this is still useful, if a user has a Thoughts @avalmas-programize @bencpeters |
No I think keeping all three roles is much useful and easier to maintain. |
id: user.id, | ||
administeredReefs: newAdministeredReefs, | ||
}; | ||
return this.usersRepository.save(newUser); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if we pass in an id, should we use .update instead of save?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updating ManyToMany relations is not working properly with update
, only with save
.
typeorm/typeorm#2821
// eslint-disable-next-line fp/no-mutation | ||
priorAccount.adminLevel = newUser.adminLevel; | ||
} | ||
|
||
const data = { | ||
...priorAccount, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we reverse the order here so that priorAccount info is prioritized over the createUserDto?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we want to do that?
I mean the createUserDto has the information added from the user in the registration form.
reef-to-admin
table (custom intermediate table for manyToMany relationship between admin and reef)reefs/:reef_id/surveys
so that we always track the reef_id, which is needed for IsReefAdminGuardSome front-end refactor is needed due to the prefix change
Related issue: #111