Skip to content

Commit

Permalink
feat: user groups (#26)
Browse files Browse the repository at this point in the history
* fix local-dev

* add a whole bunch of group related stuff

* implement add/remove members

* setup groups to hide shift signup unless your group is gtg

* add helper functions, fix serverside comparison
  • Loading branch information
dtp263 authored Apr 27, 2024
1 parent 58c73e9 commit 2f40b9a
Show file tree
Hide file tree
Showing 26 changed files with 920 additions and 60 deletions.
75 changes: 75 additions & 0 deletions packages/backend/controllers/group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Knex from 'knex';
import { DateTime } from 'luxon';
import knexConfig from '../knexfile';
import { getConfig } from '../config/config';
import Group from '../models/group/group';
import GroupViewModel from '../view_models/group';
import User from '../models/user/user';

const knex = Knex(knexConfig[getConfig().Environment]);

export default class GroupController {
public static async GetGroupViewModels(
groups: Group[],
): Promise<GroupViewModel[]> {
const groupViewModels: Promise<GroupViewModel>[] = groups.map(
async (group): Promise<GroupViewModel> => {
const members = await Group.relatedQuery('members').for(group.id);
if (!members) {
return {
group,
members: [],
};
}

return {
group,
members: members.map((member) => User.fromJson(member)),
};
},
);

const viewModels: GroupViewModel[] = await Promise.all(groupViewModels);

return viewModels;
}

public static async GetAllGroupsForUser(user: User): Promise<Group[]> {
const groups = await knex<Group>('groups')
.from('group_members')
.where({
userID: user.id,
})
.join('groups', 'group_members.groupID', '=', 'groups.id');

return groups;
}

public static async UserCanSignupForShifts(
user: User,
rosterID: number,
): Promise<boolean> {
const groups = await GroupController.GetAllGroupsForUser(user);

if (groups.length === 0) {
return false;
}

groups.filter((group) => group.rosterID === rosterID);

groups.sort(
(a, b) =>
a.shiftSignupOpenDate.getTime() - b.shiftSignupOpenDate.getTime(),
);

if (
DateTime.fromJSDate(groups[0].shiftSignupOpenDate).setZone('utc', {
keepLocalTime: true,
}) > DateTime.utc()
) {
return false;
}

return true;
}
}
2 changes: 2 additions & 0 deletions packages/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import shiftsRouter from './routes/shifts';
import rolesRouter from './routes/roles';
import rostersRouter from './routes/rosters';
import rosterParticipantsRouter from './routes/roster_participants';
import groupRouter from './routes/groups';

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
Expand Down Expand Up @@ -188,6 +189,7 @@ app.use(
checkAuthenticated,
rosterParticipantsRouter,
);
app.use('/api/groups', checkAuthenticated, groupRouter);

app.use('/api/health', (req: Request, res: Response) => {
res.status(200).send('healthy');
Expand Down
5 changes: 4 additions & 1 deletion packages/backend/knexfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ const config: { [key: string]: Knex.Config } = {
connection: {
connectionString: appConfig.PostgresConnectionURL,
ssl: {
ca: fs.readFileSync(appConfig.PostgresSSLCertPath).toString(),
ca:
appConfig.Environment === 'production'
? fs.readFileSync(appConfig.PostgresSSLCertPath).toString()
: '',
},
},
pool: {
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/migrations/20240419003319_groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('groups', (table) => {
table.increments('id').primary();
table.string('name').notNullable();
table.string('description').notNullable();
table.integer('rosterID').notNullable();
table.timestamp('shiftSignupOpenDate', { useTz: false }).notNullable();
});
}

export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTableIfExists('groups');
}
12 changes: 12 additions & 0 deletions packages/backend/migrations/20240419003738_group_members.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('group_members', (table) => {
table.integer('userID');
table.integer('groupID');
});
}

export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTableIfExists('group_members');
}
73 changes: 73 additions & 0 deletions packages/backend/models/group/group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Model } from 'objection';
import { RJSFSchema } from '@rjsf/utils';
import User from '../user/user';

export default class Group extends Model {
id!: number;

name!: string;

description!: string;

rosterID!: number;

shiftSignupOpenDate!: Date;

// Table name is the only required property.
static tableName = 'groups';

// Optional JSON schema. This is not the database schema! Nothing is generated
// based on this. This is only used for validation. Whenever a model instance
// is created it is checked against this schema. http://json-schema.org/.
static jsonSchema = {
type: 'object',

properties: {
id: { type: 'integer' },
},
};

static formSchema: RJSFSchema = {
title: 'Create a Group',
type: 'object',
required: ['name', 'description', 'rosterID', 'shiftSignupOpenDate'],
properties: {
name: {
type: 'string',
title: 'Group Name',
default: '',
},
description: {
type: 'string',
title: 'Description',
default: '',
},
rosterID: {
type: 'integer',
title: 'Roster ID',
default: 0,
},
shiftSignupOpenDate: {
type: 'string',
title: 'Shift Signup Open Date',
format: 'date-time',
default: '',
},
},
};

static relationMappings = {
members: {
relation: Model.ManyToManyRelation,
modelClass: User,
join: {
from: 'groups.id',
through: {
from: 'group_members.groupID',
to: 'group_members.userID',
},
to: 'users.id',
},
},
};
}
14 changes: 10 additions & 4 deletions packages/backend/roles/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,23 @@
"name": "admin",
"description": "A Web Administrator.",
"permissions": [
"users:readAll",
"userVerification:edit",
"userVerification:readAll",
"groups:readAll",
"groups:create",
"groups:edit",
"groups:readMembers",
"groups:addMember",
"groups:removeMember",
"schedules:create",
"schedules:delete",
"shifts:create",
"shifts:delete",
"signupStatus:readAll",
"rosters:create",
"rosters:delete",
"rosterParticipant:readAll"
"rosterParticipant:readAll",
"users:readAll",
"userVerification:edit",
"userVerification:readAll"
]
}
]
Expand Down
157 changes: 157 additions & 0 deletions packages/backend/routes/groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import express, { Request, Response, Router } from 'express';
import Group from '../models/group/group';
import hasPermission from '../middleware/rbac';
import GroupController from '../controllers/group';

const router: Router = express.Router();

/* GET all group viewModels. */
router.get(
'/viewModels',
hasPermission('groups:readAll'),
async (req: Request, res: Response) => {
const groups = await Group.query();

const viewModels = await GroupController.GetGroupViewModels(groups);
res.json(viewModels);
},
);

/* GET a group by ID. */
router.get(
'/:id',
hasPermission('groups:read'),
async (req: Request, res: Response) => {
const groupID = req.params.id;

const group = await Group.query().findById(groupID);

if (!group) {
res.status(404).send('Group not found');
return;
}

res.json(group);
},
);

/* POST a new group. */
router.post(
'/',
hasPermission('groups:create'),
async (req: Request, res: Response) => {
const newGroup = req.body;

console.log(newGroup);

const group = await Group.query().insert(newGroup);

res.json(group);
},
);

/* UPDATE a group. */
router.put(
'/:id',
hasPermission('groups:edit'),
async (req: Request, res: Response) => {
const updatedGroup = req.body;
const groupID = req.params.id;

const group = await Group.query().findById(groupID);

if (!group) {
res.status(404).send('Group not found');
return;
}

await Group.query().findById(groupID).patch(updatedGroup);

res.json(updatedGroup);
},
);

// GET members of a group
router.get(
'/:id/members',
hasPermission('groups:readMembers'),
async (req: Request, res: Response) => {
const groupID = req.params.id;

if (!groupID || groupID === 'undefined') {
res.status(400).send('Group ID is required');
return;
}

const members = await Group.relatedQuery('members').for(groupID);

if (!members) {
res.status(404).send('Members not found');
return;
}

res.json(members);
},
);

// POST a new member to a group
router.post(
'/:id/members/:memberID',
hasPermission('groups:addMember'),
async (req: Request, res: Response) => {
const { id: groupID, memberID: newMemberID } = req.params;

if (!groupID || groupID === 'undefined') {
res.status(400).send('Group ID is required');
return;
}

if (!newMemberID || newMemberID === 'undefined') {
res.status(400).send('Member ID is required');
return;
}

const group = await Group.query().findById(groupID);

if (!group) {
res.status(404).send('Group not found');
return;
}

await group.$relatedQuery('members').relate(newMemberID);

res.json(true);
},
);

// DELETE a member from a group
router.delete(
'/:id/members/:memberID',
hasPermission('groups:removeMember'),
async (req: Request, res: Response) => {
const { id: groupID, memberID } = req.params;

if (!groupID || groupID === 'undefined') {
res.status(400).send('Group ID is required');
return;
}

if (!memberID || memberID === 'undefined') {
res.status(400).send('Member ID is required');
return;
}

const group = await Group.query().findById(groupID);

if (!group) {
res.status(404).send('Group not found');
return;
}

await group.$relatedQuery('members').unrelate().where('id', memberID);

res.json(true);
},
);

export default router;
Loading

0 comments on commit 2f40b9a

Please sign in to comment.