diff --git a/migrations/20210825151547_add_group_kind_to_groups.ts b/migrations/20210825151547_add_group_kind_to_groups.ts new file mode 100644 index 00000000..b7d4a5ba --- /dev/null +++ b/migrations/20210825151547_add_group_kind_to_groups.ts @@ -0,0 +1,16 @@ +import * as Knex from 'knex' + +export async function up(knex: Knex): Promise { + 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 { + return knex.schema.alterTable('groups', table => { + table.dropColumn('type') + }) +} diff --git a/migrations/20210825160429_add_group_role_to_users_groups.ts b/migrations/20210825160429_add_group_role_to_users_groups.ts new file mode 100644 index 00000000..3de199df --- /dev/null +++ b/migrations/20210825160429_add_group_role_to_users_groups.ts @@ -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 { + // 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 { + // TODO: drop enum `group_roles` + + await knex.schema.alterTable('users_groups', (table) => { + table.dropColumn('group_role') + }) +} diff --git a/public/js/group/create.js b/public/js/group/create.js index 9092ab1b..6d4b43d6 100644 --- a/public/js/group/create.js +++ b/public/js/group/create.js @@ -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) { @@ -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 @@ -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 diff --git a/seeds/02_add_groups.ts b/seeds/02_add_groups.ts index 215d2480..36674b1f 100644 --- a/seeds/02_add_groups.ts +++ b/seeds/02_add_groups.ts @@ -23,7 +23,7 @@ export async function seed(knex: Knex): Promise { // 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 @@ -32,31 +32,35 @@ export async function seed(knex: Knex): Promise { 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) diff --git a/src/components/groups/group.middlewares.ts b/src/components/groups/group.middlewares.ts index 173f3fdf..3cd6a54f 100644 --- a/src/components/groups/group.middlewares.ts +++ b/src/components/groups/group.middlewares.ts @@ -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' @@ -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!') @@ -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.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') @@ -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!', @@ -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') @@ -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') @@ -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 @@ -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 }), @@ -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)) ] } @@ -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 { @@ -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 & { type: string } if (type !== 'floor') { return next() } diff --git a/src/components/groups/group.routes.ts b/src/components/groups/group.routes.ts index 757c9021..ec55ad2a 100644 --- a/src/components/groups/group.routes.ts +++ b/src/components/groups/group.routes.ts @@ -4,18 +4,19 @@ import { formatDistanceStrict } from 'date-fns' import huLocale from 'date-fns/locale/hu' -import { Request, Response, Router} from 'express' +import { Request, Response, Router } from 'express' import multer from 'multer' import { isAuthenticated } from '../../config/passport' import { DATE_FORMAT, ROOMS } from '../../util/constants' import { handleValidationError, checkIdParam } from '../../util/validators' -import { RoleType, User } from '../users/user' +import { RoleType } from '../users/user' import { joinGroup, sendEmailToOwner, leaveGroup, isMemberInGroup, + approveMember, kickMember, sendEmailToMember, createICSEvent, @@ -25,12 +26,15 @@ import { checkValidMaxAttendeeLimit } from './group.middlewares' import { createGroup, getGroup, getGroups, updateGroup, removeGroup } from './group.service' +import { GroupRole } from './grouprole' +import { GroupType } from './group' const router = Router() router.get('/', isAuthenticated, getGroups, (req, res) => { res.render('group/index', { groups: req.groups, + past: req.query.past, paginationOpt: req.paginationOptions, dateFns: { format, @@ -47,6 +51,7 @@ router.get('/new', isAuthenticated, (req, res) => start: (req.query?.start as string)?.split(' ')[0].slice(0, -3), end: (req.query?.end as string)?.split(' ')[0].slice(0, -3), roomId: req.query?.roomId, + GroupType, ROOMS }) ) @@ -59,7 +64,7 @@ router.post('/', checkConflicts, createGroup, joinGroup, - (req: Request, res: Response) => res.sendStatus(201) + (req: Request, res: Response) => res.status(201).json({ id: req.group.id }) ) router.get('/:id', @@ -67,11 +72,28 @@ router.get('/:id', checkIdParam, getGroup, (req, res) => { - const joined = req.group.users.some(u => u.id === (req.user as User).id) - const isOwner = req.group.ownerId === (req.user as User).id - const isAdmin = (req.user as User).role == RoleType.ADMIN + const { group } = req + const user = req.user + const userId = user.id + + const joined = group.users.some(u => u.id === userId) + const isOwner = group.ownerId === userId + const isAdmin = user.role == RoleType.ADMIN + const canSeeMembers = isOwner || isAdmin || group.canSeeMembers(userId) + const canModerate = isOwner || isAdmin res.render('group/show', { - group: req.group, joined, isOwner, format, DATE_FORMAT, isAdmin + group, + joined, + isOwner, + format, + DATE_FORMAT, + isAdmin, + canSeeMembers, + canModerate, + GroupType, + GroupRole, + userId, + userRole: group.users.find(x => x.id === userId)?.groupRole }) }) @@ -89,6 +111,17 @@ router.post('/:id/leave', leaveGroup, (req, res) => res.redirect('/groups') ) + +router.post('/:id/approve/:userid', + isAuthenticated, + getGroup, + isGroupOwnerOrAdmin, + isMemberInGroup, + approveMember, + sendEmailToMember, + (req, res) => res.redirect(`/groups/${req.params.id}`) +) + router.post('/:id/kick/:userid', isAuthenticated, getGroup, @@ -119,6 +152,8 @@ router.get('/:id/copy', name: req.group.name, description: req.group.description, tags: req.group.tags, + type: req.group.type, + GroupType, ROOMS }) ) @@ -142,7 +177,9 @@ router.get('/:id/edit', ROOMS, isEditing: true, groupId: req.group.id, - maxAttendees: req.group.maxAttendees + maxAttendees: req.group.maxAttendees, + type: req.group.type, + GroupType }) ) @@ -157,7 +194,7 @@ router.put('/:id', checkConflicts, checkValidMaxAttendeeLimit, updateGroup, - (req: Request, res: Response) => res.sendStatus(201) + (req: Request, res: Response) => res.status(201).json({ id: req.group.id }) ) router.get('/:id/export', isAuthenticated, checkIdParam, getGroup, createICSEvent) diff --git a/src/components/groups/group.service.ts b/src/components/groups/group.service.ts index c5167037..2cdec0fa 100644 --- a/src/components/groups/group.service.ts +++ b/src/components/groups/group.service.ts @@ -1,14 +1,17 @@ -import { Request, Response, NextFunction} from 'express' +import { Request, Response, NextFunction } from 'express' -import { Group } from './group' -import { User } from '../users/user' +import { Group, GroupType } from './group' import { formatMdToSafeHTML } from '../../util/convertMarkdown' import { asyncWrapper } from '../../util/asyncWrapper' export const getGroups = asyncWrapper(async (req: Request, res: Response, next: NextFunction) => { const page = isNaN(Number(req.query.page)) ? 0 : Number(req.query.page) const limit = 20 - const pageObject = await Group.query().orderBy('createdAt', 'DESC').page(page, limit) + const pageObject = req.query.past === 'true' ? + await Group.query().where('endDate', '<', new Date()) + .orderBy('startDate', 'DESC').page(page, limit) : + await Group.query().where('endDate', '>=', new Date()) + .orderBy('startDate', 'ASC').page(page, limit) req.groups = pageObject.results.map(group => { const raw = group.description.slice(0, 50) + (group.description.length > 50 ? ' ...' : '') group.description = formatMdToSafeHTML(raw) @@ -28,11 +31,11 @@ export const getGroup = asyncWrapper(async (req: Request, res: Response, next: N .withGraphFetched('users') if (group) { - // Getting raw description for /copy and /edit pages - if (/\/copy|\/edit/.test(req.path)) - req.group = group - else - req.group = { ...group, description: formatMdToSafeHTML(group.description) } as Group + req.group = group + // Except when getting raw description for /copy and /edit pages + if (!/\/copy|\/edit/.test(req.path)) { + req.group.description = formatMdToSafeHTML(group.description) + } next() } else { res.render('error/not-found') @@ -52,8 +55,9 @@ export const createGroup = asyncWrapper(async (req: Request, res: Response, next doNotDisturb: !!req.body.doNotDisturb, startDate: new Date(req.body.startDate), endDate: new Date(req.body.endDate), - ownerId: (req.user as User).id, - maxAttendees: parseInt(req.body.maxAttendees) || 100 + ownerId: req.user.id, + maxAttendees: parseInt(req.body.maxAttendees) || 100, + type: req.body.groupType as GroupType } ) @@ -73,7 +77,8 @@ export const updateGroup = asyncWrapper(async (req: Request, res: Response, next doNotDisturb: !!req.body.doNotDisturb, startDate: new Date(req.body.startDate), endDate: new Date(req.body.endDate), - maxAttendees: parseInt(req.body.maxAttendees) || 100 + maxAttendees: parseInt(req.body.maxAttendees) || 100, + type: req.body.groupType }) .findById(req.params.id) .catch((err) => { diff --git a/src/components/groups/group.ts b/src/components/groups/group.ts index 598d2469..f9adcb30 100644 --- a/src/components/groups/group.ts +++ b/src/components/groups/group.ts @@ -1,6 +1,13 @@ import { Model } from 'objection' import { User } from '../users/user' +import { GroupMember } from './groupMember' +import { GroupRole } from './grouprole' + +export enum GroupType { + classic = 'CLASSIC', + private = 'PRIVATE' +} export class Group extends Model { id!: number @@ -14,9 +21,20 @@ export class Group extends Model { place?: string doNotDisturb: boolean ownerId: number - users: User[] + users: GroupMember[] createdAt: Date maxAttendees: number + type = GroupType.classic + + isApproved(userId: User['id']): boolean { + return this.users.some( + x => x.id === userId && x.groupRole !== GroupRole.unapproved) + } + + + canSeeMembers(userId: User['id']): boolean { + return this.type !== GroupType.private || this.isApproved(userId) + } $beforeInsert(): void { this.createdAt = new Date() @@ -38,7 +56,8 @@ export class Group extends Model { // ManyToMany relation needs the `through` object to describe the join table. through: { from: 'users_groups.groupId', - to: 'users_groups.userId' + to: 'users_groups.userId', + extra: ['group_role'] }, to: 'users.id' } @@ -63,7 +82,8 @@ export class Group extends Model { place: { type: 'string' }, startDate: { type: 'datetime' }, endDate: { type: 'datetime' }, - maxAttendees: { type: 'integer' } + maxAttendees: { type: 'integer' }, + type: { type: 'string' } } } } diff --git a/src/components/groups/groupMember.ts b/src/components/groups/groupMember.ts new file mode 100644 index 00000000..84404c2f --- /dev/null +++ b/src/components/groups/groupMember.ts @@ -0,0 +1,17 @@ +import { User } from '../users/user' +import { GroupRole } from './grouprole' + +/** + * User with role + */ +export class GroupMember extends User { + groupRole: GroupRole + + isOwner(): boolean { + return this.groupRole === GroupRole.owner + } + + isApproved(): boolean { + return this.groupRole !== GroupRole.unapproved + } +} diff --git a/src/components/groups/grouprole.ts b/src/components/groups/grouprole.ts new file mode 100644 index 00000000..0a9848ed --- /dev/null +++ b/src/components/groups/grouprole.ts @@ -0,0 +1,8 @@ +/** + * Role of user inside group +*/ +export enum GroupRole { + owner = 'OWNER', + member = 'MEMBER', + unapproved = 'UNAPPROVED' +} diff --git a/src/components/tickets/ticket.service.ts b/src/components/tickets/ticket.service.ts index 814fc57e..ccba89b0 100644 --- a/src/components/tickets/ticket.service.ts +++ b/src/components/tickets/ticket.service.ts @@ -10,7 +10,7 @@ import { User } from '../users/user' export const getOtherTickets = asyncWrapper( async (req: Request, _res: Response, next: NextFunction) => { - req.otherTickets = (await Ticket.query().where('userId', '!=', (req.user as User).id) + req.otherTickets = (await Ticket.query().where('userId', '!=', req.user.id) .orderBy('createdAt', 'ASC')).map(ticket => { ticket.description = formatMdToSafeHTML(ticket.description) return ticket @@ -20,7 +20,7 @@ export const getOtherTickets = asyncWrapper( export const getMyTickets = asyncWrapper( async (req: Request, _res: Response, next: NextFunction) => { - req.myTickets = (await Ticket.query().where('userId', '=', (req.user as User).id) + req.myTickets = (await Ticket.query().where('userId', '=', req.user.id) .orderBy('createdAt', 'ASC')).map(ticket => { ticket.description = formatMdToSafeHTML(ticket.description) return ticket @@ -36,7 +36,7 @@ export const createTicket = asyncWrapper( { roomNumber: +req.body.roomNumber, description: req.body.description, - userId: (req.user as User).id, + userId: req.user.id, } ) }) @@ -84,7 +84,7 @@ export const removeTicket = asyncWrapper( export const checkTicketOwner = asyncWrapper( async (req: Request, res: Response, next: NextFunction) => { const ticket = await Ticket.query().findOne({ id: parseInt(req.params.id) }) - if (ticket.userId == (req.user as User).id) { + if (ticket.userId == req.user.id) { next() } else { return res.sendStatus(403) diff --git a/src/components/users/groupOfTheUser.ts b/src/components/users/groupOfTheUser.ts new file mode 100644 index 00000000..ec926a51 --- /dev/null +++ b/src/components/users/groupOfTheUser.ts @@ -0,0 +1,9 @@ +import { Group } from '../groups/group' +import { GroupRole } from '../groups/grouprole' + +/** + * Group with role + */ +export class GroupOfTheUser extends Group { + groupRole: GroupRole +} diff --git a/src/components/users/user.middlewares.ts b/src/components/users/user.middlewares.ts index 7cadffa1..aaef629c 100644 --- a/src/components/users/user.middlewares.ts +++ b/src/components/users/user.middlewares.ts @@ -1,9 +1,7 @@ import { Request, Response, NextFunction } from 'express' -import { User } from './user' - export const isSameUser = (req: Request, res: Response, next: NextFunction): void => { - if (parseInt(req.params.id) !== (req.user as User).id) { + if (parseInt(req.params.id) !== req.user.id) { res.status(400).json({ errors: [{ msg: 'Nem találahtó felhasználó a megadott ID-val' }] }) } else { next() diff --git a/src/components/users/user.routes.ts b/src/components/users/user.routes.ts index 61e8bf98..173a1833 100644 --- a/src/components/users/user.routes.ts +++ b/src/components/users/user.routes.ts @@ -4,24 +4,35 @@ import { ROLES } from '../../util/constants' import { requireRoles, isAuthenticated } from '../../config/passport' import { handleValidationError, checkIdParam } from '../../util/validators' -import { RoleType, User } from './user' +import { RoleType } from './user' import { isSameUser } from './user.middlewares' -import { getUser, updateRole, updateUser } from './user.service' +import { getGroupsOfTheUser, getUser, updateRole, updateUser } from './user.service' +import { GroupType } from '../groups/group' +import { GroupRole } from './../groups/grouprole' const router = Router() -router.get('/:id', isAuthenticated, checkIdParam, getUser, (req, res) => - res.render('user/show', { - userToShow: req.userToShow, - ROLES: ROLES - }) +router.get('/:id', + isAuthenticated, + checkIdParam, + getUser, + getGroupsOfTheUser, + (req, res) => + res.render('user/show', { + userToShow: req.userToShow, + groupsOfTheUser: req.groupsOfTheUser, + GroupType, + GroupRole, + userId: req.user.id, + ROLES: ROLES + }) ) router.patch('/:id/role', requireRoles(RoleType.ADMIN), check('role') .isString() - .custom((input) => { + .custom((input) => { return [...ROLES.keys()] .some((element) => element == input) }) @@ -34,7 +45,7 @@ router.patch('/:id/role', router.patch('/:id', isAuthenticated, (req, res, next) => { - if ((req.user as User).id !== parseInt(req.params.id)) { + if (req.user.id !== parseInt(req.params.id)) { return res.sendStatus(403) } next() diff --git a/src/components/users/user.service.ts b/src/components/users/user.service.ts index 8c33d5ee..f558bb33 100644 --- a/src/components/users/user.service.ts +++ b/src/components/users/user.service.ts @@ -27,6 +27,16 @@ export const getUser = asyncWrapper(async (req: Request, res: Response, next: Ne } }) +export const getGroupsOfTheUser = asyncWrapper( + async (req: Request, res: Response, next: NextFunction) => { + const user = await User.query() + .findOne({ id: req.user.id }) + .withGraphFetched('groups') + + req.groupsOfTheUser = user.groups + next() + }) + export const updateRole = asyncWrapper(async (req: Request, res: Response, next: NextFunction) => { const user = await User.query().findOne({ id: parseInt(req.params.id) }) @@ -41,7 +51,7 @@ export const updateRole = asyncWrapper(async (req: Request, res: Response, next: }) export const updateUser = asyncWrapper(async (req: Request, res: Response, next: NextFunction) => { - const id = (req.user as User).id + const id = req.user.id const { floor, wantEmail } = req.body req.user = await User.query().patchAndFetchById(id, { floor, wantEmail }) diff --git a/src/components/users/user.ts b/src/components/users/user.ts index d0fd1cbc..485bfbf7 100644 --- a/src/components/users/user.ts +++ b/src/components/users/user.ts @@ -1,6 +1,6 @@ import { Model } from 'objection' -import { Group } from '../groups/group' +import { GroupOfTheUser } from './groupOfTheUser' export enum RoleType { ADMIN = 'ADMIN', @@ -17,7 +17,7 @@ export class User extends Model { role: RoleType floor: number wantEmail: boolean - groups: Group[] + groups: GroupOfTheUser[] static get tableName(): string { return 'users' } @@ -27,13 +27,14 @@ export class User extends Model { return { groups: { relation: Model.ManyToManyRelation, - modelClass: Group, + modelClass: GroupOfTheUser, join: { from: 'users.id', through: { from: 'users_groups.userId', - to: 'users_groups.groupId' + to: 'users_groups.groupId', + extra: ['group_role'] }, to: 'groups.id' } @@ -52,7 +53,7 @@ export class User extends Model { name: { type: 'string', minLength: 1, maxLength: 255 }, authSchId: { type: 'string' }, floor: { type: ['integer', 'null'] }, - wantEmail: { type: 'boolean'} + wantEmail: { type: 'boolean' } } } } diff --git a/src/config/passport.ts b/src/config/passport.ts index e9450378..ff2abbc4 100644 --- a/src/config/passport.ts +++ b/src/config/passport.ts @@ -79,7 +79,7 @@ export const isAuthenticated = export const requireRoles = (...roles: RoleType[]) => { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ return (req: Request, res: Response, next: NextFunction): Response> => { - const role = (req.user as User)?.role + const role = req.user?.role if (roles.some((element) => role == element)) { next() } else { diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 6b1357f9..e46dd3f8 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -2,6 +2,7 @@ import { Group } from '../components/groups/group' import { Ticket } from '../components/tickets/ticket' import { User as LocalUser } from '../components/users/user' import { PaginationOptions } from '../components/groups/paginationOptions' +import { GroupOfTheUser } from '../components/users/groupOfTheUser' declare global { namespace Express { @@ -12,7 +13,11 @@ declare global { paginationOptions: PaginationOptions group: Group userToShow: LocalUser + + groupsOfTheUser: GroupOfTheUser[] } + + interface User extends LocalUser {} } } diff --git a/views/group/index.pug b/views/group/index.pug index 8c4aed2d..ea135769 100644 --- a/views/group/index.pug +++ b/views/group/index.pug @@ -1,9 +1,20 @@ extends ../layouts/main +include ./pagination/activeButton.pug +include ./pagination/inactiveButton.pug + block content div(class='flex flex-row items-center space-x-4') h1(class='title') Csoportok a(href='/groups/new' class='text-xl btn btn-primary animate-hover') Új csoport + div(class='w-1/2 mx-auto text-xl font-bold sm:text-lg') + div(class='my-5 flex flex-row items-center justify-center space-x-2') + if past === 'true' + +inactiveButton('/groups', 'Következők') + +activeButton('/groups?past=true', 'Korábbiak') + else + +activeButton('/groups', 'Következők') + +inactiveButton('/groups?past=true', 'Korábbiak') if paginationOpt.pageNum > 1 include ./pagination/index diff --git a/views/group/new.pug b/views/group/new.pug index a7fceb91..ec09fe6f 100644 --- a/views/group/new.pug +++ b/views/group/new.pug @@ -51,6 +51,19 @@ block content div(class='flex flex-col items-start') label(class='text-lg font-bold' for="pickerEnd") Befejezés ideje input(type="text", id="pickerEnd", name="endDate", required, data-input, class="flatpickr") + div + label(for="groupType" class='text-lg font-bold') Csoport típusa + br + select(id="groupType" name="groupType" class='flatpickr') + option(value=`${GroupType.classic}` selected=(type === GroupType.classic || !type)) Klasszikus + option(value=`${GroupType.private}` selected=(type === GroupType.private)) Privát + ul + li + span(class='font-bold') Klasszikus - + span Bárki csatlakozhat a csoporthoz + li + span(class='font-bold') Privát - + span A csatlakozást meg kell erősítenie a szervezőnek. Külsősök számára a taglista rejtett. div label(for="desc" class='text-lg font-bold') Leírás textarea(name="description", id="desc") diff --git a/views/group/pagination/index.pug b/views/group/pagination/index.pug index 255e0fb0..9875968d 100644 --- a/views/group/pagination/index.pug +++ b/views/group/pagination/index.pug @@ -3,26 +3,27 @@ include ./inactiveButton.pug div(class='my-5') nav(role="pagination", aria-label="pagination" class='w-1/2 mx-auto text-xl font-bold sm:text-lg') + -var baseUrl = `/groups?${past ? 'past=' + past + '&' : ''}page=` if paginationOpt.pageNum <= 5 div(class='flex flex-row items-center justify-center space-x-2') - for (i = 0; i < paginationOpt.pageNum; i++) { if i == paginationOpt.current //- current page - +activeButton(`/groups?page=${i}`, i + 1) + +activeButton(baseUrl + `${i}`, i + 1) else - +inactiveButton(`/groups?page=${i}`, i + 1) + +inactiveButton(baseUrl + `${i}`, i + 1) - } else div(class='flex flex-row items-center justify-center space-x-2') if paginationOpt.current >= 2 - +inactiveButton(`/groups?page=0`, 1) + +inactiveButton(baseUrl + '0', 1) span … if paginationOpt.current >= 1 - +inactiveButton(`/groups?page=${paginationOpt.current - 1}`, paginationOpt.current) + +inactiveButton(baseUrl + `${paginationOpt.current - 1}`, paginationOpt.current) //- current page - +activeButton(`/groups?page=${paginationOpt.current}`, paginationOpt.current + 1) + +activeButton(baseUrl + `${paginationOpt.current}`, paginationOpt.current + 1) if paginationOpt.current <= paginationOpt.pageNum - 2 - +inactiveButton(`/groups?page=${paginationOpt.current + 1}`, paginationOpt.current + 2) + +inactiveButton(baseUrl + `${paginationOpt.current + 1}`, paginationOpt.current + 2) if paginationOpt.current <= paginationOpt.pageNum - 3 span … - +inactiveButton(`/groups?page=${paginationOpt.pageNum - 1}`, paginationOpt.pageNum) + +inactiveButton(baseUrl + `${paginationOpt.pageNum - 1}`, paginationOpt.pageNum) diff --git a/views/group/show.pug b/views/group/show.pug index 5b8731bf..ca475bbd 100644 --- a/views/group/show.pug +++ b/views/group/show.pug @@ -89,6 +89,12 @@ block content path(stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z') span Nyílt sup(title='Szabad a csatlakozás') ? + if group.type === GroupType.private + li(class='flex flex-row items-center space-x-2') + //- Heroicon name: user-group + svg.h-6.w-6(xmlns='http://www.w3.org/2000/svg' fill='none' viewbox='0 0 24 24' stroke='currentColor') + path(stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z') + span Privát csoport li(class='flex flex-row items-center space-x-2') if group.room //- Heroicon name: office-building @@ -105,7 +111,7 @@ block content svg(xmlns='http://www.w3.org/2000/svg' alt="Más hely" aria-label="Más hely" fill='none' viewbox='0 0 24 24' stroke='currentColor' class='w-6 h-6') path(stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z') span(class='text-lg sm:text-xl')= group.place - + if startDate == endDate li(class='flex flex-row items-center space-x-2') //- Heroicon name: calendar @@ -154,17 +160,35 @@ block content div(class='flex flex-col items-start space-y-2 text-xl') each user in group.users div(class='w-full flex flex-row justify-between items-center ml-6 space-x-2') - a(href=`/users/${user.id}` class='lg:whitespace-no-wrap hover:text-blue-500')= user.name - if user.id === group.ownerId - span(class='px-3 py-1 text-sm text-white bg-purple-600 rounded-full') Szervező - else if isOwner || isAdmin - button(type='button' class='px-3 py-1 text-sm text-white btn btn-primary animate-hover cursor-pointer' onClick=`toggleModal( - '/groups/${group.id}/kick/${user.id}', - 'Biztosan ki akarod rúgni ${user.name}-t?', - 'Biztosan ki akarod rúgni ${user.name}-t? Ezt később nem tudod visszavonni!', - 'Kirúgás', - false)`) Kirúgás - + - nameVisible = canModerate || group.canSeeMembers(userId) || user.groupRole === GroupRole.owner || user.id === userId + if nameVisible + a(href=`/users/${user.id}` class='lg:whitespace-no-wrap hover:text-blue-500')= user.name + else + a(href="#" class='lg:whitespace-no-wrap hover:text-blue-500') Anonymous + div(class='flex space-x-1 flex-wrap justify-end') + case user.groupRole + when GroupRole.owner + span(class='px-3 py-1 text-sm text-white bg-purple-600 rounded-full') Szervező + when GroupRole.unapproved + if !canModerate || user.id === userId + span(class='px-3 py-1 text-sm text-white bg-gray-600 rounded-full') Jelentkező + else if canModerate && !groupEnded + button(type='button' class='px-3 py-1 text-sm text-white btn btn-primary bg-green-600 hover:bg-green-700 animate-hover cursor-pointer' onClick=`toggleModal( + '/groups/${group.id}/approve/${user.id}', + 'Biztosan fel akarod venni ${user.name}-t?', + 'Biztosan fel akarod venni ${user.name}-t? Ezt később nem tudod visszavonni!', + 'Felvétel', + false)`) Felvétel + when GroupRole.member + if !nameVisible + span(class='px-3 py-1 text-sm text-white bg-green-600 rounded-full') Csoporttag + if canModerate && user.groupRole !== GroupRole.owner + button(type='button' class='px-3 py-1 text-sm text-white btn btn-primary animate-hover cursor-pointer' onClick=`toggleModal( + '/groups/${group.id}/kick/${user.id}', + 'Biztosan ki akarod rúgni ${user.name}-t?', + 'Biztosan ki akarod rúgni ${user.name}-t? Ezt később nem tudod visszavonni!', + 'Kirúgás', + false)`) Kirúgás +modalTemplate() block scripts diff --git a/views/user/show.pug b/views/user/show.pug index 444ea92e..f687be2c 100644 --- a/views/user/show.pug +++ b/views/user/show.pug @@ -23,35 +23,50 @@ block content option(value=`${key}`, selected=key==userToShow.role) #{ROLES.get(key)} button(type="button", id="submitBtn", onclick=`updateRole(${userToShow.id})`, class='ml-2 btn btn-primary animate-hover') Módosítás - - const isUpcoming = (element) => element.endDate > Date.now(); - - const isOld = (element) => element.endDate <= Date.now() + - const isPublic = (group) => group.type === GroupType.classic + - const isOwner = (group) => group.groupRole === GroupRole.owner + - const isAlsoMember = (group) => groupsOfTheUser.some(g => g.id === group.id && g.groupRole !== GroupRole.unapproved) + - const isVisible = (group) => isPublic(group) || isOwner(group) || isAlsoMember(group) + - const isUpcoming = (group) => group.endDate > Date.now(); + - const isOld = (group) => group.endDate <= Date.now() + + mixin groupWidget(group) + a(href=`/groups/${group.id}` class='transition duration-300 ease-in-out transform hover:-translate-y-2') + div(class='flex flex-col items-center p-6 border rounded-md dark:border-gray-500 hover:border-gray-400 animate-hover dark:hover:border-gray-300') + span= group.name + div(class='flex items-center') + mixin roleLabel(color, title) + span(class=`px-3 py-1 mx-1 mt-1 text-sm text-white ${color} rounded-full`)= title + case group.groupRole + when GroupRole.owner + +roleLabel('bg-purple-800', 'Szervező') + when GroupRole.unapproved + +roleLabel('bg-gray-800', 'Jelentkező') + when GroupRole.member + +roleLabel('bg-green-800', 'Csoporttag') + div(class='flex mt-1 items-center justify-center') + if !isPublic(group) + //- Heroicon name: eye-off + svg.h-5.w-5(xmlns='http://www.w3.org/2000/svg' alt="Privát csoport" aria-label="Privát csoport" viewbox='0 0 20 20' fill='currentColor') + path(fill-rule='evenodd' d='M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z' clip-rule='evenodd') + path(d='M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z') + if user.id === group.ownerId + //- Heroicon name: shield-check + svg.h-5.w-5(xmlns='http://www.w3.org/2000/svg' alt="Saját esemény" aria-label="Saját esemény" viewbox='0 0 20 20' fill='currentColor') + path(fill-rule='evenodd' d='M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z' clip-rule='evenodd') + div(class='space-y-2') - if userToShow.groups.some(isUpcoming) + if userToShow.groups.filter(isVisible).some(isUpcoming) h3(class='mb-1 text-lg') Közelgő csoportesemények div(class='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4') - each group in userToShow.groups.filter(isUpcoming) - a(href=`/groups/${group.id}` class='transition duration-300 ease-in-out transform hover:-translate-y-2') - div(class='flex items-center justify-center p-6 border rounded-md dark:border-gray-500 hover:border-gray-400 animate-hover dark:hover:border-gray-300') - div(class='flex flex-row items-center') - span= group.name - if user.id === group.ownerId - //- Heroicon name: shield-check - svg(xmlns="http://www.w3.org/2000/svg" alt="Saját esemény" aria-label="Saját esemény" viewbox="0 0 20 20" fill="currentColor" class="w-4 h-4 ml-1") - path(fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd") + each group in userToShow.groups.filter(isVisible).filter(isUpcoming) + +groupWidget(group) - if userToShow.groups.some(isOld) + if userToShow.groups.filter(isVisible).some(isOld) h3(class='mb-2 text-lg') Elmúlt csoportesemények div(class='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4') - each group in userToShow.groups.filter(isOld) - a(href=`/groups/${group.id}` class='transition duration-300 ease-in-out transform hover:-translate-y-2') - div(class='flex items-center justify-center p-6 border rounded-md dark:border-gray-500 hover:border-gray-400 animate-hover dark:hover:border-gray-300') - div(class='flex flex-row items-center') - span= group.name - if user.id === group.ownerId - //- Heroicon name: shield-check - svg(xmlns="http://www.w3.org/2000/svg" alt="Saját esemény" aria-label="Saját esemény" viewbox="0 0 20 20" fill="currentColor" class="w-4 h-4 ml-1") - path(fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd") - + each group in userToShow.groups.filter(isVisible).filter(isOld) + +groupWidget(group) block scripts script(src="/js/user.js")