Skip to content
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

Feat/330 private groups #753

Open
wants to merge 24 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9d26d2b
Temporary change authentication
tomitheninja Jun 30, 2021
9740606
Create column groups.kind
tomitheninja Aug 25, 2021
7c4dc44
Convert enum values to uppercase
tomitheninja Aug 25, 2021
6c6e902
Apply db changes for anonymous groups
tomitheninja Aug 26, 2021
1efce8e
Assign groupRole to user when joining
tomitheninja Aug 26, 2021
84aaffb
Fix: req.group is not an instance of Group
tomitheninja Aug 27, 2021
69b1130
Add missing whitespaces
tomitheninja Aug 27, 2021
fbf0872
Implement api route to approve joining requests
tomitheninja Aug 27, 2021
033ff1f
Implement front end for the new role system
tomitheninja Aug 27, 2021
ea9406a
Rename GroupKind.Anonymous to GroupKind.Private
tomitheninja Aug 27, 2021
ad1ca1f
Add GroupKind selector to the newGroup form
tomitheninja Aug 27, 2021
ebd94d0
Send different email for private groups
tomitheninja Aug 27, 2021
d73f54e
Implement GroupKind changing for group editing
tomitheninja Aug 27, 2021
df5451c
Implement GroupKind for group copy
tomitheninja Aug 27, 2021
787584d
Show private groups on user profile when viewing self
tomitheninja Aug 27, 2021
4503571
Show event on the user's profile if the event is owned by the viewer
tomitheninja Aug 27, 2021
858a174
Remove a console.log
tomitheninja Aug 27, 2021
d573f3b
Revert "Temporary change authentication"
tomitheninja Jun 30, 2021
8a33968
Rename GroupKind to GroupType
tomitheninja Aug 27, 2021
0cc3ec2
Remove a console.log
tomitheninja Aug 28, 2021
2cda550
Create service for fetching groups of the user
tomitheninja Aug 28, 2021
287a065
Do not hide group members and show role labels
tomitheninja Aug 28, 2021
e7118e4
Remove duplicate role checks
tomitheninja Aug 28, 2021
45060df
Merge branch 'master' into feat/330-anon-join
tomitheninja Aug 28, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions migrations/20210825151547_add_group_kind_to_groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Knex from 'knex'

export async function up(knex: Knex): Promise<void> {
return knex.schema.alterTable('groups', table => {
table.enu('type', ['CLASSIC', 'PRIVATE'], {
useNative: true,
enumName: 'group_type'
}).defaultTo('CLASSIC')
})
}

export async function down(knex: Knex): Promise<void> {
return knex.schema.alterTable('groups', table => {
table.dropColumn('type')
})
}
31 changes: 31 additions & 0 deletions migrations/20210825160429_add_group_role_to_users_groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as Knex from 'knex'

const ENUM_VALUES = ['OWNER', 'MEMBER', 'UNAPPROVED']
const ENUM_CONFIG = { useNative: true, enumName: 'group_roles' }
export async function up(knex: Knex): Promise<void> {
// create role
await knex.schema.alterTable('users_groups', (table) =>
table
.enu('group_role', ENUM_VALUES, ENUM_CONFIG)
.defaultTo('MEMBER')
.notNullable()
)

// save owners
const ownerJoins = knex('users_groups')
.select('users_groups.id')
.join('groups', 'group_id', '=', 'groups.id')
.whereRaw('user_id = owner_id')

await knex('users_groups')
.update('group_role', 'OWNER')
.where('id', 'in', ownerJoins)
}

export async function down(knex: Knex): Promise<void> {
// TODO: drop enum `group_roles`
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop the enum on migrate:down, or else the second migrate:up will fail, because the enum already exists

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could use some help with this one 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried searching knex docs but didn't really find anything, worst case you can use .raw with DROP TYPE group_roles.


await knex.schema.alterTable('users_groups', (table) => {
table.dropColumn('group_role')
})
}
18 changes: 9 additions & 9 deletions public/js/group/create.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
let meetingPlace = 'floor'
const MEETING_PLACES = ['floor', 'link', 'other']
const MEETING_PLACES = ['floor', 'link', 'other']

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function selectMeetingPlace(kind) {
Expand All @@ -26,12 +26,12 @@ function isValidHttpsUrl(str) {
return false
} // not catching bad top lvl domain (1 character)

const pattern = new RegExp('^(https?:\\/\\/)?'+ // protocol
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // domain name
'((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR ip (v4) address
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // port and path
'(\\?[;&a-z\\d%_.~+=-]*)?'+ // query string
'(\\#[-a-z\\d_]*)?$','i') // fragment locator
const pattern = new RegExp('^(https?:\\/\\/)?' + // protocol
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
'(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
'(\\#[-a-z\\d_]*)?$', 'i') // fragment locator
// not allowing '(' and ')'
// catching 1 character TLD

Expand Down Expand Up @@ -84,13 +84,13 @@ const validateGroup = (data) => {
}

const handleResponse = async (res, edited) => {
const data = await res.json()
switch (res.status) {
case 201:
sendMessage(`Csoport sikeresen ${edited ? 'frissítve' : 'létrehozva'}`, 'success')
location.href = '/groups'
location.href = `/groups/${data.id}`
break
case 400:
const data = await res.json()
clearMessages()
data.errors.forEach((err) => displayMessage(err.msg))
break
Expand Down
16 changes: 10 additions & 6 deletions seeds/02_add_groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export async function seed(knex: Knex): Promise<void> {


// Compose group
const startSeed = faker.datatype.number(500) * 1_000_000 * (faker.datatype.boolean()? -1 : 1)
const startSeed = faker.datatype.number(500) * 1_000_000 * (faker.datatype.boolean() ? -1 : 1)
const maxTwoHours = faker.datatype.number(6600) * 1000 + 600_000
const ownerId = (i * groupsPerFloor + j) % 16 + 1
const groupId = (i * groupsPerFloor + j) + 1
Expand All @@ -32,31 +32,35 @@ export async function seed(knex: Knex): Promise<void> {
name: faker.company.catchPhrase(),
tags: faker.random.words(faker.datatype.number(5)).split(' ').join(','),
description,
startDate: (new Date( Date.now() + startSeed * (j + 1) )),
endDate: (new Date( Date.now() + startSeed * (j + 1) + maxTwoHours )),
startDate: (new Date(Date.now() + startSeed * (j + 1))),
endDate: (new Date(Date.now() + startSeed * (j + 1) + maxTwoHours)),
room: i + startingFloor,
doNotDisturb: (i * groupsPerFloor + j) % 16 == 1,
maxAttendees: 100,
createdAt: new Date(),
ownerId
ownerId,
type: (i + j) % 3 === 0 ? 'CLASSIC' : 'PRIVATE'
}
console.log('\x1b[33m%s\x1b[0m',
`Group: #${groupId} ${group.name}, floor: ${group.room}, owner: ${group.ownerId}`)
`Group: #${groupId} ${group.name}, floor: ${group.room}, owner: ${ownerId}`)
groupArray.push(group)

// Connect groups and users
const connectOwner = {
userId: (i * groupsPerFloor + j) % 16 + 1,
groupId: (i * groupsPerFloor + j) + 1,
groupRole: 'OWNER'
}
console.log(`Connect: owner #${connectOwner.userId} to #${connectOwner.groupId}`)
connectArray.push(connectOwner)
connectCountSum++

for (let k = 1; k < ownerId; ++k) {
const isUnapproved = group.type === 'PRIVATE' && (k % 2 === 0 || k % 3 === 0)
const connect = {
userId: k,
groupId
groupId,
groupRole: isUnapproved ? 'UNAPPROVED' : 'MEMBER'
}
console.log(`Connect: user #${connect.userId} to #${connect.groupId}`)
connectArray.push(connect)
Expand Down
110 changes: 75 additions & 35 deletions src/components/groups/group.middlewares.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Email } from './../../util/sendEmail'
import { NextFunction, Request, Response } from 'express'
import { check, ValidationChain } from 'express-validator'
import { writeFileSync } from 'fs'
Expand All @@ -6,18 +7,23 @@ import winston from 'winston'
import { differenceInMinutes } from 'date-fns'

import { RoleType, User } from '../users/user'
import { Group } from './group'
import { Group, GroupType } from './group'
import { asyncWrapper } from '../../util/asyncWrapper'
import sendMessage from '../../util/sendMessage'
import { sendEmail } from '../../util/sendEmail'
import { GroupRole } from './grouprole'

export const joinGroup = asyncWrapper(async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User
const user = req.user
const group = req.group

let role: GroupRole | null = null

// Join group if not already in it, and it's not closed or it's the owner who joins.
// We only join the group if it is not full already
if (group.doNotDisturb && (user.id !== group.ownerId)){
if (user.id == group.ownerId) {
role = GroupRole.owner
} else if (group.doNotDisturb) {
sendMessage(res, 'Ez egy privát csoport!')
} else if (group.users?.find(it => it.id === user.id)) {
sendMessage(res, 'Már tagja vagy ennek a csoportnak!')
Expand All @@ -26,47 +32,74 @@ export const joinGroup = asyncWrapper(async (req: Request, res: Response, next:
} else if (group.endDate < new Date()) {
sendMessage(res, 'Ez a csoport már véget ért!')
} else {
role = group.type === GroupType.private ? GroupRole.unapproved : GroupRole.member
}

if (role !== null) {
await Group.relatedQuery('users')
.for(group.id)
.relate(user.id)
.relate({
id: user.id,
group_role: role
} as unknown)
return next()
}

res.redirect(`/groups/${req.params.id}`)
})

export const sendEmailToOwner = asyncWrapper(
async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User
async (req: Request, res: Response, next: NextFunction) => {
const user = req.user
const group = req.group

const emailRecepient = await User.query().findOne({ id: group.ownerId })
sendEmail([emailRecepient], {
subject: 'Csatlakoztak egy csoportodba!',
body: `${user.name} csatlakozott a(z) ${group.name} csoportodba!`,
link: `/groups/${group.id}`,
linkTitle: 'Csoport megtekintése'
})
const emails: Record<GroupType, Email> = {
[GroupType.classic]: {
subject: 'Csatlakoztak egy csoportodba!',
body: `${user.name} csatlakozott a(z) ${group.name} csoportodba!`,
link: `/groups/${group.id}`,
linkTitle: 'Csoport megtekintése'
},
[GroupType.private]: {
subject: 'Csatlakoznának egy csoportodba!',
body: `${user.name} csatlakozna a(z) ${group.name} csoportodba!`,
link: `/groups/${group.id}`,
linkTitle: 'Csoport megtekintése'
}
}
sendEmail([emailRecepient], emails[group.type])
next()
})
export const leaveGroup = asyncWrapper(async (req: Request, res: Response, next: NextFunction) => {
await Group.relatedQuery('users')
.for(req.group.id)
.unrelate()
.where('user_id', (req.user as User).id)
.where('user_id', req.user.id)

next()
})

export const isMemberInGroup =
asyncWrapper(async (req: Request, res: Response, next: NextFunction) => {
const kickableUser = await Group.relatedQuery('users').for(req.group.id)
.findOne({ userId: parseInt(req.params.userid) })
if (kickableUser) {
asyncWrapper(async (req: Request, res: Response, next: NextFunction) => {
const kickableUser = await Group.relatedQuery('users').for(req.group.id)
.findOne({ userId: parseInt(req.params.userid) })
if (kickableUser) {
next()
} else {
res.redirect('/not-found')
}
})

export const approveMember = asyncWrapper(
async (req: Request, res: Response, next: NextFunction) => {
await Group.relatedQuery('users')
.for(req.group.id)
.patch({ group_role: GroupRole.member } as unknown)
.where('user_id', req.params.userid)

next()
} else {
res.redirect('/not-found')
}
})
})

export const kickMember = asyncWrapper(async (req: Request, res: Response, next: NextFunction) => {
await Group.relatedQuery('users')
Expand All @@ -78,7 +111,7 @@ export const kickMember = asyncWrapper(async (req: Request, res: Response, next:
})

export const sendEmailToMember = asyncWrapper(
async (req: Request, res: Response, next: NextFunction) => {
async (req: Request, res: Response, next: NextFunction) => {
const emailRecepient = await User.query().findOne({ id: req.params.userid })
sendEmail([emailRecepient], {
subject: 'Kirúgtak egy csoportból!',
Expand All @@ -92,7 +125,7 @@ export const sendEmailToMember = asyncWrapper(
*/
export const isGroupOwner = asyncWrapper(
async (req: Request, res: Response, next: NextFunction) => {
if ((req.user as User)?.id === req.group.ownerId) {
if (req.user?.id === req.group.ownerId) {
next()
} else {
res.render('error/forbidden')
Expand All @@ -102,8 +135,8 @@ export const isGroupOwner = asyncWrapper(

export const isGroupOwnerOrAdmin = asyncWrapper(
async (req: Request, res: Response, next: NextFunction) => {
if (((req.user as User)?.id === req.group.ownerId)
|| ((req.user as User)?.role == RoleType.ADMIN)) {
if ((req.user?.id === req.group.ownerId)
|| (req.user?.role == RoleType.ADMIN)) {
next()
} else {
res.render('error/forbidden')
Expand Down Expand Up @@ -162,12 +195,12 @@ function isValidHttpsUrl(str) {
return false
} // not catching bad top lvl domain (1 character)

const pattern = new RegExp('^(https?:\\/\\/)?'+ // protocol
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // domain name
'((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR ip (v4) address
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // port and path
'(\\?[;&a-z\\d%_.~+=-]*)?'+ // query string
'(\\#[-a-z\\d_]*)?$','i') // fragment locator
const pattern = new RegExp('^(https?:\\/\\/)?' + // protocol
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
'(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
'(\\#[-a-z\\d_]*)?$', 'i') // fragment locator
// not allowing '(' and ')'
// catching 1 character TLD

Expand Down Expand Up @@ -211,7 +244,7 @@ export const validateGroup = (): ValidationChain[] => {
.custom((value, { req }) => new Date(value).getTime() < new Date(req.body.endDate).getTime())
.withMessage('A kezdés nem lehet korábban, mint a befejezés')
.custom((value, { req }) =>
differenceInMinutes(new Date(req.body.endDate), new Date(value)) <= 5*60)
differenceInMinutes(new Date(req.body.endDate), new Date(value)) <= 5 * 60)
.withMessage('A foglalás időtartama nem lehet hosszabb 5 óránál'),
check('endDate', 'A befejezés időpontja kötelező')
.exists({ checkFalsy: true, checkNull: true }),
Expand All @@ -221,7 +254,14 @@ export const validateGroup = (): ValidationChain[] => {
.isLength({ max: 500 }),
check('maxAttendees', 'Legalább 1, maximum 100 fő vehet részt!')
.optional({ checkFalsy: true })
.isInt({ min: 1, max: 100 })
.isInt({ min: 1, max: 100 }),
check('groupType', 'Hibás a csoport típusa')
.optional()
.isString()
.trim()
.default(GroupType.classic)
.toUpperCase()
.isIn(Object.values(GroupType))
]
}

Expand All @@ -230,7 +270,7 @@ export const checkValidMaxAttendeeLimit = asyncWrapper(
if (req.group.users.length > (req.body.maxAttendees || 100)) {
res.status(400).json(
{
errors: [{msg: 'Nem lehet kisebb a maximum jelenlét, mint a jelenlegi'}]
errors: [{ msg: 'Nem lehet kisebb a maximum jelenlét, mint a jelenlegi' }]
}
)
} else {
Expand All @@ -241,7 +281,7 @@ export const checkValidMaxAttendeeLimit = asyncWrapper(

export const checkConflicts = asyncWrapper(
async (req: Request, res: Response, next: NextFunction) => {
const { type, ...group } = req.body as Group & { type: string }
const { type, ...group } = req.body as Omit<Group, 'type'> & { type: string }
if (type !== 'floor') {
return next()
}
Expand Down
Loading