From 79a3bc022ec8bffa6942b311c277e6f02de78221 Mon Sep 17 00:00:00 2001 From: Jumpy Squirrel Date: Sat, 23 Mar 2024 17:29:01 +0100 Subject: [PATCH 1/7] feat(#239): set up separate docker tag --- .github/workflows/docker-build-push.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index b16d6fa..e47ce7b 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -4,6 +4,7 @@ on: push: branches: - 'main' + - 'mmc-room-groups' tags: - 'v[0-9]+.[0-9]+.[0-9]+' @@ -41,7 +42,7 @@ jobs: REGISTRY_USER: ${{ github.actor }} REGISTRY_PASS: ${{ secrets.GITHUB_TOKEN }} - - name: Docker build and push 'latest' image + - name: Docker build and push 'latest-mmc' image if: startsWith(github.ref, 'refs/heads/') run: > TAG_ARGS=$(echo -n "$IMAGE_TAGS" | sed -r "s_([^ :/]+)_ --tag $REGISTRY/$IMAGE_NAME:\1 _g") && @@ -56,7 +57,7 @@ jobs: env: REGISTRY: 'ghcr.io' IMAGE_NAME: ${{ github.repository }} - IMAGE_TAGS: latest + IMAGE_TAGS: latest-mmc FULL_REPO_URL: "https://github.com/${{ github.repository }}" COMMIT_HASH: ${{ github.sha }} From f0ccf92d426f8f57638aba398dd0e9869ae41dfd Mon Sep 17 00:00:00 2001 From: Jumpy Squirrel Date: Fri, 19 Apr 2024 17:24:24 +0200 Subject: [PATCH 2/7] feat(#239): draft groups API implementation --- src/apis/common.ts | 4 +- src/apis/roomsrv.ts | 406 +++++++++++++++++++++++++++++++++++++ src/config.ts | 4 + src/state/models/errors.ts | 4 +- src/util/config-types.ts | 4 + 5 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 src/apis/roomsrv.ts diff --git a/src/apis/common.ts b/src/apis/common.ts index 0010b04..0a9f481 100644 --- a/src/apis/common.ts +++ b/src/apis/common.ts @@ -11,7 +11,7 @@ export class LoginRequiredError extends Error { } } -export interface ErrorDto { +export interface ErrorDto { // The time at which the error occurred. readonly timestamp: string // 2006-01-02T15:04:05+07:00 @@ -19,7 +19,7 @@ export interface ErrorDto { readonly requestid: string // a8b7c6d5 // A keyed description of the error. We do not write human-readable text here because the user interface will be multi-language. - readonly message: ErrorMessage // attendee.owned.notfound or similar + readonly message: ErrorMessageEnum // attendee.owned.notfound or similar // Optional additional details about the error. If available, will usually contain English language technobabble. readonly details: Readonly> diff --git a/src/apis/roomsrv.ts b/src/apis/roomsrv.ts new file mode 100644 index 0000000..e6e774c --- /dev/null +++ b/src/apis/roomsrv.ts @@ -0,0 +1,406 @@ +/* eslint-disable camelcase */ +import { catchError } from 'rxjs/operators' +import { ajax, AjaxConfig, AjaxError } from 'rxjs/ajax' +import config from '~/config' +import { ErrorDto as CommonErrorDto, handleStandardApiErrors } from './common' +import { AppError } from '~/state/models/errors' +import type { Replace } from 'type-fest' + +export type RoomErrorMessage = + | 'auth.forbidden' + | 'auth.unauthorized' + | 'group.data.duplicate' + | 'group.data.invalid' + | 'group.id.invalid' + | 'group.id.notfound' + | 'group.invite.mismatch' + | 'group.mail.error' + | 'group.member.duplicate' + | 'group.member.notfound' + | 'group.owner.notfound' + | 'group.owner.cannot.remove' + | 'group.parse.error' + | 'group.read.error' + | 'group.write.error' + | 'room.group.notfound' + | 'room.id.invalid' + | 'room.id.notfound' + | 'room.read.error' + | 'unknown' + +export type RoomErrorDto = CommonErrorDto + +export interface RoomCountdownDto { + readonly currentTime: string + readonly targetTime: string + readonly countdown: number + readonly secret: string +} + +export interface MemberDto { + readonly id: number // badge number + readonly nickname: string + readonly avatar: string // url to avatar, may be empty + readonly hasKey: boolean +} + +export type GroupFlag = + | 'public' // visible in listing for approved attendees + | 'wheelchair' // require handicap accessibility + +export interface GroupDto { + readonly id: string + readonly name: string + readonly flags: GroupFlag[] + readonly comments: string + readonly maximum_size: number + readonly owner: number // badge number + readonly members: MemberDto[] + readonly invites: MemberDto[] +} + +export interface GroupListDto { + readonly groups: GroupDto[] +} + +export interface GroupCreateDto { + readonly name: string + readonly flags: GroupFlag[] + readonly comments: string +} + +export type RoomFlag = + | 'wheelchair' // has handicap accessibility + +export interface RoomDto { + readonly id: string + readonly name: string + readonly flags: RoomFlag[] + readonly comments: string + readonly size: number + readonly members: MemberDto[] +} + +export class RoomSrvAppError extends AppError> { + // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types + constructor(err: AjaxError) { + const errDto : RoomErrorDto = err.response as RoomErrorDto + + super('roomsrv', errDto.message.replaceAll('.', '-'), `Room API Error: ${JSON.stringify(errDto, undefined, 2)}`) + } +} + +// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types +const apiCall = ({ path, ...cfg }: Omit & { path: string }) => ajax({ + url: `${config.apis.roomsrv.url}${path}`, + crossDomain: true, + withCredentials: true, + ...cfg, +}).pipe( + catchError(handleStandardApiErrors(RoomSrvAppError)), +) + +/* + * GET /countdown checks if room reservation / forming room groups is open. + * + * Replies with + * - a CountdownDto and http status 200, + * - or RoomErrorDto with message "auth.unauthorized" and http status 401, + * - If room booking is limited to approved attendees (configurable), may also respond with RoomErrorDto with + * message "auth.unauthorized" and http status 403. + * + * If the countdown dto contains countdown = 0, room reservations are open. + * + * For a **youth-hostel style** conventions, there is not really a need to reserve a room, + * every registration includes one bed in some room, as long as the attendee is in status approved. + * The convention admins will only approve as many people as they have beds in the youth hostel. + * The only thing an attendee can do is form a room group to influence who else will be in the room + * with them. At a certain time before the convention, the admins will assign groups to rooms, + * possibly putting multiple groups in the same room to fill it. At that point, room groups are locked down + * for ordinary attendees. The only reason to set a starting time for forming room groups is to avoid + * load on the server during initial registration. + * + * For a **hotel-style** convention, where attendees can buy a room as an addition to their registration, + * there may not necessarily be enough hotel rooms for everyone. For these conventions, + * the attendee forms a room group to control who else will be rooming with them, and the + * room group is assigned 1:1 to a hotel room of matching size. For these situations, + * if demand exceeds supply, setting a starting time for room reservations ensures a fair + * chance at room distribution. + * + * For a **self-booking style** convention, where every attendee books directly with the hotel and + * needs a secret code to do so, this is the only endpoint needed, and all other endpoints + * will refuse to work with a 403. When the starting time is reached (countdown = 0), + * the field "secret" will be filled with a secret code to use for booking via phone or email. + * The registration system does not know who successfully booked a room, and bookings + * have no impact on the invoice (that's the point of this style - the hotel may have an exclusive contract + * with a booking provider, or the convention may wish to avoid liability for the hotel booking + * cost, or a number of other reasons to stay out of the transaction such as travel insurance laws + * or sales tax). + * + * **Note**: the starting time may depend on who asks for it. Staff may have an earlier room + * booking start than normal attendees. Or sponsors. Or something. Also, the secret code may + * be different for staff and non-staff. Don't cache this between different attendees. + * + * 401: The user is not correctly logged in, or the token has expired, and you need to + * redirect the user to the auth start, possibly setting some return URL as dropoff so the user can return + * to the current place, which should then check this endpoint again. + * + * 403: The user is not currently allowed to participate in room booking / room groups. + * Maybe they do not have a valid registration, or are not yet approved. Note that for + * self-booking style conventions, needing a valid registration may not be necessary + * for using this endpoint, depending on backend configuration. + * + * This endpoint is optimized in the backend for high traffic, so it is safe to call during initial + * room booking. + */ +export const roomCountdownCheck = () => apiCall({ + path: '/countdown', + method: 'GET', +}) + +/* + * POST /groups creates a new group. + * + * Replies with the resource location in the location header (ending in the assigned uuid), or RoomErrorDto. + * + * The current user is made owner of the group, and will initially be the only member. + * + * For youth hostel style conventions, they will then have the option to invite other attendees, + * or if the group is set to public, other attendees can find its uuid, and request to be included. + * Since every valid registration includes a bed in some room anyway, no invoice changes occur. + * + * For hotel style conventions, if this succeeds, this means the attendee has just reserved a room, + * and the room price has been added to their invoice. They may invite others to their room, up to the + * room capacity, and thus share the cost. + * + * Each attendee can be a member of at most one group, and they lose it if cancelled, e.g. due to failure to pay. + * + * 201: success. + * + * 400: This indicates a bug in this app because any validation errors should have been caught during field validation. + * The RoomErrorDto's details field will contain english language messages that describe the error in detail. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + * + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * + * 409: Duplicate (same group name), or this user already is in a group (use /groups/my to check). + * + * 500: It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + * + * This endpoint is optimized in the backend for high traffic, so it is safe to call during initial room booking. + */ +export const createNewGroup = (group: GroupCreateDto) => apiCall({ + path: '/groups', + method: 'POST', + body: group, +}) + +/* + * GET /groups/my obtains the group the current user belongs to. + * + * Returns GroupDto and status 200, or RoomErrorDto and 401, 403, 404, 500. + * + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: This user does not have a valid registration and thus is not eligible for groups. + * 404: This user, while eligible, is not currently in a group. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const findMyGroup = () => apiCall({ + path: '/groups/my', + method: 'GET', +}) + +/* + * GET /groups?show=public lists public groups, which may be available to request joining. + * + * Returns GroupListDto and status 200, or RoomErrorDto and 401, 403, 500. + * + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: This user does not have a valid registration and thus may not list groups (bars e.g. cancelled regs from viewing public groups) + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const findPublicGroups = () => apiCall({ + path: '/groups?show=public', + method: 'GET', +}) + +/* + * GET /groups/{uuid} reads a specific group. + * + * Returns GroupDto and status 200, or RoomErrorDto and 400, 401, 403, 404, 500. + * + * 400: the uuid was not valid + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: This user does not have access to this group. + * 404: No such group. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const getGroup = (uuid: string) => apiCall({ + path: `/groups/${uuid}`, + method: 'GET', +}) + +/* + * PUT /groups/{uuid} updates a specific group. + * + * Note that you cannot use this to change the group members! + * + * Returns status 204, or RoomErrorDto and 400, 401, 403, 404, 409, 500. + * + * 400: the uuid or request body was not valid, or you tried to make changes to the group members. + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: This user does not have permission to update this group. + * 404: No such group. + * 409: Your changes would turn this group into a duplicate (for example same name as existing other group) + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const updateGroup = (uuid: string, group: GroupDto) => apiCall({ + path: `/groups/${uuid}`, + method: 'PUT', + body: group +}) + +/* + * DELETE /groups/{uuid} deletes a group. + * + * Note that you cannot use this while there are still any group members other than the owner! You must kick them out first. + * + * Deleting a group will automatically expire all pending invites. + * + * Returns status 204, or RoomErrorDto and 400, 401, 403, 404, 409, 500. + * + * 400: the uuid was not valid. + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: This user does not have permission to delete this group (not its owner and not an admin). + * 404: No such group. + * 409: There were still other people in the group. Must remove them first so only the owner remains. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const deleteGroup = (uuid: string) => apiCall({ + path: `/groups/${uuid}`, + method: 'DELETE' +}) + +/* + * POST /groups/{uuid}/members/{badgenumber} invite or add a group member + * + * Adds an attendee to a group, or invites them, or accepts an already existing invitation. + * + * **Limitations** + * + * Only attending attendees can be added to a group or invited to groups. + * + * Attendees cannot be in more than one group. Attendees who are members of any group cannot be invited to any group. + * + * The moment an attendee becomes a member of any group, any existing invitations for that attendee are discarded. + * + * For a single group, there can be only one invitation for each attendee. + * + * The total number of invitations plus members of a group cannot exceed its size. Example: If your + * group has 2 members, and maximum group size is 4, you can invite at most 2 attendees. This is to prevent + * invitation spam. + * + * If an attendee is already in a group, or has already been individually assigned to a room, then they + * cannot be added to a group any more. + * + * **Case 1: Owner invites first** + * + * The owner may use this to make an invitation to their group. This will create an invitation, including a code that + * the invited attendee will need to join the group. (Exception: if the invited attendee has declined a previous + * invitation and specified that they do not desire further invitations to this group (see DELETE description), + * the invitation attempt will be auto-denied.) + * + * For the request, the owner needs to supply the nickname as additional parameter, to prove that they know who + * they are inviting (a little bit of extra protection against invitation spam). + * + * The attendee then uses this same endpoint (with the code) to accept the invitation, thus becoming a member. + * The attendee can decline an invitation by instead sending DELETE. + * + * To accept the invite, the attendee needs to supply the invitation code as additional parameter. + * + * **Case 2: Attendee (not owner) requests to join** + * + * If a group has the "public" flag, an attendee can request to join it without an active invitation. + * This will create a self-initiated invitation. (Exception: if the owner has declined a previous such invitation + * and specified that they do not desire further inquiries from this attendee (see DELETE description), + * the inquiry will be auto-denied.) + * + * For the request, all the attendee needs to know is the group uuid, and the group needs to have the public flag. + * + * When a self-initiated invitation exists, the group owner approves it by using this same endpoint. The + * attendee then becomes a member. The owner can decline by instead sending DELETE. + * + * For the approval, no extra parameters are needed. + * + * **Responses:** + * + * 204: success + * 400: invalid group id or badge number supplied + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: Permission denied. + * 404: Attendee or group not found, or wrong nickname, or wrong invitation code, or not a public group. + * 409: Duplicate assignment, or this attendee is already in another group, or has been individually assigned to a room. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const joinOrInviteToGroup = (uuid: string, badgenumber: number, nickname?: string, code?: string) => apiCall({ + path: `/groups/${uuid}/members/${badgenumber}?nickname=${nickname}&code=${code}`, + method: 'POST' +}) + +/* + * DELETE /groups/{uuid}/members/{badgenumber} decline or remove a group member. + * + * Removes the attendee with the given badge number from the group (or its list of invitations). Possibly + * also add an entry to the group's auto-deny list. + * + * **Permissions** + * + * Group owners can remove members/revoke invitations. + * + * Members can remove themselves/decline invitations. + * + * **Limitations** + * + * If a member is the current group owner, this fails with 409 conflict. First must reassign the group owner via + * an update to the group resource. + * + * **Auto-Deny** + * + * If the autodeny parameter is set to true, in addition to removing the group membership/invitation, the + * badgenumber is added to an auto-decline list. Further attempts to invite/add this attendee into the group + * are automatically declined. + * + * **Responses:** + * + * 204: success + * 400: invalid group id or badge number supplied + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: Permission denied. + * 404: Attendee or group not found, or not a member. + * 409: Conflict, this attendee is currently the owner of the group. Either change the owner first, or disband (delete) the group completely after kicking out everyone else. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const kickOrDeclineFromGroup = (uuid: string, badgenumber: number, autodeny?: boolean) => apiCall({ + path: `/groups/${uuid}/members/${badgenumber}?autodeny=${autodeny}`, + method: 'DELETE' +}) + +/* + * GET /rooms/my obtains the room the current user has been assigned to. + * + * Visibility of this information depends on the "final" flag that is set on the room, so admins can start planning + * room assignments without them becoming immediately visible to users. + * + * This endpoint works even for admins, giving them the room they are in. + * + * Returns RoomDto and status 200, or RoomErrorDto and 401, 403, 404, 500. + * + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: The user does not have permission to see their room (maybe not an active registration?) + * 404: You are not in any rooms (that are visible to you). Note that this may happen even if the attendee actually is in a room, but the room isn't flagged as "final". This is to prevent showing premature (wrong) room information while the admins are still planning room assignments. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const findMyRoom = () => apiCall({ + path: '/rooms/my', + method: 'GET', +}) diff --git a/src/config.ts b/src/config.ts index 86ec765..adcb784 100644 --- a/src/config.ts +++ b/src/config.ts @@ -253,6 +253,10 @@ const config = checkConfig({ paysrv: { url: apiPath('/paysrv/api/rest/v1'), }, + roomsrv: { + url: apiPath('/roomsrv/api/rest/v1'), + enable: true, + } }, websiteLinks: { // these two links need to be in the footer bar on each page diff --git a/src/state/models/errors.ts b/src/state/models/errors.ts index e9611d2..2551818 100644 --- a/src/state/models/errors.ts +++ b/src/state/models/errors.ts @@ -16,10 +16,10 @@ export interface ErrorReport { readonly error: unknown } -export class AppError extends Error { +export class AppError extends Error { constructor( public category: string, - public code: ErrorCode, + public code: ErrorCodeEnum, public detailedMessage: string, ) { super(`${code} - ${detailedMessage}`) diff --git a/src/util/config-types.ts b/src/util/config-types.ts index bdcec63..5f541a6 100644 --- a/src/util/config-types.ts +++ b/src/util/config-types.ts @@ -74,6 +74,10 @@ type Config Date: Fri, 19 Apr 2024 18:32:33 +0200 Subject: [PATCH 3/7] fix(#239): make linter happy (pls have a look) --- src/apis/roomsrv.ts | 33 +++++++++++++++++++-------------- src/config.ts | 2 +- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/apis/roomsrv.ts b/src/apis/roomsrv.ts index e6e774c..a5ec1eb 100644 --- a/src/apis/roomsrv.ts +++ b/src/apis/roomsrv.ts @@ -46,17 +46,17 @@ export interface MemberDto { export type GroupFlag = | 'public' // visible in listing for approved attendees - | 'wheelchair' // require handicap accessibility + | 'wheelchair' // require handicap accessibility export interface GroupDto { readonly id: string readonly name: string - readonly flags: GroupFlag[] - readonly comments: string + readonly flags?: GroupFlag[] + readonly comments?: string readonly maximum_size: number readonly owner: number // badge number readonly members: MemberDto[] - readonly invites: MemberDto[] + readonly invites?: MemberDto[] } export interface GroupListDto { @@ -65,26 +65,27 @@ export interface GroupListDto { export interface GroupCreateDto { readonly name: string - readonly flags: GroupFlag[] - readonly comments: string + readonly flags?: GroupFlag[] + readonly comments?: string } export type RoomFlag = | 'wheelchair' // has handicap accessibility + | 'final' // visible to attendees in the room export interface RoomDto { readonly id: string readonly name: string - readonly flags: RoomFlag[] - readonly comments: string + readonly flags?: RoomFlag[] + readonly comments?: string readonly size: number - readonly members: MemberDto[] + readonly members?: MemberDto[] } export class RoomSrvAppError extends AppError> { // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types constructor(err: AjaxError) { - const errDto : RoomErrorDto = err.response as RoomErrorDto + const errDto: RoomErrorDto = err.response as RoomErrorDto super('roomsrv', errDto.message.replaceAll('.', '-'), `Room API Error: ${JSON.stringify(errDto, undefined, 2)}`) } @@ -189,6 +190,8 @@ export const roomCountdownCheck = () => apiCall({ * * This endpoint is optimized in the backend for high traffic, so it is safe to call during initial room booking. */ +// why is this necessary? How does this differ from the stuff in attsrv.ts?!? +// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types export const createNewGroup = (group: GroupCreateDto) => apiCall({ path: '/groups', method: 'POST', @@ -254,10 +257,12 @@ export const getGroup = (uuid: string) => apiCall({ * 409: Your changes would turn this group into a duplicate (for example same name as existing other group) * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. */ +// why is this necessary? How does this differ from the stuff in attsrv.ts?!? +// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types export const updateGroup = (uuid: string, group: GroupDto) => apiCall({ path: `/groups/${uuid}`, method: 'PUT', - body: group + body: group, }) /* @@ -278,7 +283,7 @@ export const updateGroup = (uuid: string, group: GroupDto) => apiCall({ */ export const deleteGroup = (uuid: string) => apiCall({ path: `/groups/${uuid}`, - method: 'DELETE' + method: 'DELETE', }) /* @@ -344,7 +349,7 @@ export const deleteGroup = (uuid: string) => apiCall({ */ export const joinOrInviteToGroup = (uuid: string, badgenumber: number, nickname?: string, code?: string) => apiCall({ path: `/groups/${uuid}/members/${badgenumber}?nickname=${nickname}&code=${code}`, - method: 'POST' + method: 'POST', }) /* @@ -382,7 +387,7 @@ export const joinOrInviteToGroup = (uuid: string, badgenumber: number, nickname? */ export const kickOrDeclineFromGroup = (uuid: string, badgenumber: number, autodeny?: boolean) => apiCall({ path: `/groups/${uuid}/members/${badgenumber}?autodeny=${autodeny}`, - method: 'DELETE' + method: 'DELETE', }) /* diff --git a/src/config.ts b/src/config.ts index adcb784..21d5004 100644 --- a/src/config.ts +++ b/src/config.ts @@ -256,7 +256,7 @@ const config = checkConfig({ roomsrv: { url: apiPath('/roomsrv/api/rest/v1'), enable: true, - } + }, }, websiteLinks: { // these two links need to be in the footer bar on each page From 5aaac002a66e19af1d79bcddb74915350993e48e Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Thu, 10 Oct 2024 18:42:56 +0200 Subject: [PATCH 4/7] Config and routes for roomshare --- .../funnels/funnels/register/steps/roomshare.tsx | 16 ++++++++++++++++ .../funnels/register/steps/roomshare/create.tsx | 11 +++++++++++ .../funnels/register/steps/roomshare/home.tsx | 11 +++++++++++ .../funnels/register/steps/roomshare/join.tsx | 7 +++++++ src/config.ts | 1 + src/navigation/router.tsx | 7 +++++++ src/navigation/routes.ts | 4 ++++ src/util/config-types.ts | 1 + 8 files changed, 58 insertions(+) create mode 100644 src/components/funnels/funnels/register/steps/roomshare.tsx create mode 100644 src/components/funnels/funnels/register/steps/roomshare/create.tsx create mode 100644 src/components/funnels/funnels/register/steps/roomshare/home.tsx create mode 100644 src/components/funnels/funnels/register/steps/roomshare/join.tsx diff --git a/src/components/funnels/funnels/register/steps/roomshare.tsx b/src/components/funnels/funnels/register/steps/roomshare.tsx new file mode 100644 index 0000000..c1f9fa4 --- /dev/null +++ b/src/components/funnels/funnels/register/steps/roomshare.tsx @@ -0,0 +1,16 @@ +import { ReadonlyRouteComponentProps } from '~/util/readonly-types' +import { Router } from '@reach/router' +import { withPrefix } from 'gatsby' +import * as ROUTES from '~/navigation/routes' +import RoomShareJoin from '~/components/funnels/funnels/register/steps/roomshare/join' +import RoomShareHome from '~/components/funnels/funnels/register/steps/roomshare/home' +import RoomShareCreate from '~/components/funnels/funnels/register/steps/roomshare/create' + +const RoomShare = (_: ReadonlyRouteComponentProps) => + + + ` + + + +export default RoomShare diff --git a/src/components/funnels/funnels/register/steps/roomshare/create.tsx b/src/components/funnels/funnels/register/steps/roomshare/create.tsx new file mode 100644 index 0000000..b9434b5 --- /dev/null +++ b/src/components/funnels/funnels/register/steps/roomshare/create.tsx @@ -0,0 +1,11 @@ +import type { ReadonlyRouteComponentProps } from '~/util/readonly-types' + +const RoomShareCreate = (_: ReadonlyRouteComponentProps) => { + return ( +
+ {/**/} +
+ ) +} + +export default RoomShareCreate diff --git a/src/components/funnels/funnels/register/steps/roomshare/home.tsx b/src/components/funnels/funnels/register/steps/roomshare/home.tsx new file mode 100644 index 0000000..5a34391 --- /dev/null +++ b/src/components/funnels/funnels/register/steps/roomshare/home.tsx @@ -0,0 +1,11 @@ +import type { ReadonlyRouteComponentProps } from '~/util/readonly-types' + +const RoomShareHome = (_: ReadonlyRouteComponentProps) => { + return ( +
+

RoomShareHome

+
+ ) +} + +export default RoomShareHome diff --git a/src/components/funnels/funnels/register/steps/roomshare/join.tsx b/src/components/funnels/funnels/register/steps/roomshare/join.tsx new file mode 100644 index 0000000..81c719d --- /dev/null +++ b/src/components/funnels/funnels/register/steps/roomshare/join.tsx @@ -0,0 +1,7 @@ +import type { ReadonlyRouteComponentProps } from '~/util/readonly-types' + +const RoomShareJoin = (_: ReadonlyRouteComponentProps) => { + return
RoomShareJoin
+} + +export default RoomShareJoin diff --git a/src/config.ts b/src/config.ts index 21d5004..b141686 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,6 +25,7 @@ const config = checkConfig({ dayTicketEndDate: DateTime.fromISO('2024-09-21', { zone: 'Europe/Berlin' }), earliestBirthDate: DateTime.fromISO('1901-01-01'), minimumAge: 18, + enableRoomshare: true, // For development allowedCountries: ['AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AC', 'AU', 'AT', 'AZ', 'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR', 'IO', 'BN', 'BG', 'BF', 'BI', 'CV', 'KH', 'CM', 'CA', 'KY', 'CF', 'EA', 'TD', 'CL', 'CN', 'CX', 'CP', 'CC', 'CO', 'KM', 'CG', 'CD', 'CK', 'CR', 'HR', 'CU', 'CW', 'CY', 'CZ', 'CI', 'DK', 'DG', 'DJ', 'DM', 'DO', 'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'SZ', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF', 'GA', 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY', 'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', 'JM', 'JP', 'JE', 'JO', 'IC', 'KZ', 'KE', 'KI', 'KP', 'KR', 'KW', 'KG', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY', 'LI', 'LT', 'LU', 'MO', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'MX', 'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'MM', 'NA', 'NR', 'NP', 'NL', 'NC', 'NZ', 'NI', 'NE', 'NG', 'NU', 'NF', 'MK', 'MP', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH', 'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', 'BL', 'SH', 'KN', 'LC', 'MF', 'PM', 'VC', 'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SX', 'SK', 'SI', 'SB', 'SO', 'ZA', 'GS', 'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SE', 'CH', 'SY', 'TW', 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK', 'TO', 'TT', 'TA', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'UM', 'US', 'UY', 'UZ', 'VU', 'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW'], ticketLevels: { 'standard': { diff --git a/src/navigation/router.tsx b/src/navigation/router.tsx index d352d8f..ddba477 100644 --- a/src/navigation/router.tsx +++ b/src/navigation/router.tsx @@ -10,23 +10,30 @@ import Room from '~/components/funnels/funnels/hotel-booking/steps/room' import Guests from '~/components/funnels/funnels/hotel-booking/steps/guests' import AdditionalInfo from '~/components/funnels/funnels/hotel-booking/steps/additional-info' import Email from '~/components/funnels/funnels/hotel-booking/steps/email' +import RoomShare from '~/components/funnels/funnels/register/steps/roomshare' import * as ROUTES from './routes' import { withPrefix } from 'gatsby' import { useAppSelector } from '~/hooks/redux' import { isEditMode } from '~/state/selectors/register' +import config from '~/config' export const EFRouter = () => +const RoomShareRoutes = () => + + export const RegisterRouter = () => { const isEdit = useAppSelector(isEditMode()) + const enableRoomShare = config.enableRoomshare return + {enableRoomShare && } diff --git a/src/navigation/routes.ts b/src/navigation/routes.ts index a64cd32..1afa948 100644 --- a/src/navigation/routes.ts +++ b/src/navigation/routes.ts @@ -9,6 +9,10 @@ export const REGISTER_TICKET_LEVEL = 'level' export const REGISTER_PERSONAL = 'personal-info' export const REGISTER_CONTACT = 'contact-info' export const REGISTER_OPTIONAL = 'optional-info' +export const REGISTER_ROOM_SHARE = 'room-share' +export const REGISTER_ROOM_SHARE_HOME = 'home' +export const REGISTER_ROOM_SHARE_JOIN = 'join' +export const REGISTER_ROOM_SHARE_CREATE = 'create' export const REGISTER_SUMMARY = 'summary' export const REGISTER_THANK_YOU = 'thank-you' diff --git a/src/util/config-types.ts b/src/util/config-types.ts index 5f541a6..ccdedb1 100644 --- a/src/util/config-types.ts +++ b/src/util/config-types.ts @@ -45,6 +45,7 @@ type Config Date: Tue, 5 Nov 2024 15:18:07 +0100 Subject: [PATCH 5/7] feat(#239): update api calls to match current implementation --- README.md | 10 ++++ src/apis/roomsrv.ts | 129 +++++++++++++++++++++++++++++++------------- 2 files changed, 103 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 6dfd8c9..d2e89e2 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,13 @@ scp public.tgz regtest@reg.eurofurence.org:projects/ ssh regtest@reg.eurofurence.org -t "bash -l -c 'scripts/update-app.sh'" rm -f public.tgz ``` + +### other commands + +``` +# run the typescript compiler to check for type errors +npm run tsc + +# run the linter to check for warnings and errors +npm run lint +``` diff --git a/src/apis/roomsrv.ts b/src/apis/roomsrv.ts index a5ec1eb..6ea03e7 100644 --- a/src/apis/roomsrv.ts +++ b/src/apis/roomsrv.ts @@ -7,22 +7,30 @@ import { AppError } from '~/state/models/errors' import type { Replace } from 'type-fest' export type RoomErrorMessage = + | 'attendee.validation.error' // attendee service downstream failure (mostly during permission check) + | 'attendee.notfound' + | 'attendee.status.not.attending' | 'auth.forbidden' | 'auth.unauthorized' + | 'group.ban.duplicate' + | 'group.ban.notfound' | 'group.data.duplicate' | 'group.data.invalid' | 'group.id.invalid' | 'group.id.notfound' | 'group.invite.mismatch' | 'group.mail.error' + | 'group.member.conflict' | 'group.member.duplicate' | 'group.member.notfound' - | 'group.owner.notfound' + | 'group.owner.notingroup' | 'group.owner.cannot.remove' | 'group.parse.error' | 'group.read.error' + | 'group.size.full' | 'group.write.error' - | 'room.group.notfound' + | 'http.error.internal' + | 'request.parse.failed' | 'room.id.invalid' | 'room.id.notfound' | 'room.read.error' @@ -38,10 +46,10 @@ export interface RoomCountdownDto { } export interface MemberDto { - readonly id: number // badge number - readonly nickname: string + readonly id: number // badge number, may be 0 if masked entry + readonly nickname: string // may be empty if masked entry readonly avatar: string // url to avatar, may be empty - readonly hasKey: boolean + readonly flags: string[] // currently unused } export type GroupFlag = @@ -49,13 +57,13 @@ export type GroupFlag = | 'wheelchair' // require handicap accessibility export interface GroupDto { - readonly id: string - readonly name: string + readonly id: string // uuid of the group + readonly name: string // name of the group, up to 80 characters readonly flags?: GroupFlag[] readonly comments?: string readonly maximum_size: number readonly owner: number // badge number - readonly members: MemberDto[] + readonly members: MemberDto[] // a group will always contain at least its owner readonly invites?: MemberDto[] } @@ -67,6 +75,8 @@ export interface GroupCreateDto { readonly name: string readonly flags?: GroupFlag[] readonly comments?: string + // maximum size defaults to configuration value + // for non-admins, owner will always be the user making the creation request } export type RoomFlag = @@ -74,12 +84,12 @@ export type RoomFlag = | 'final' // visible to attendees in the room export interface RoomDto { - readonly id: string - readonly name: string + readonly id: string // uuid of the room + readonly name: string // up to 80 characters readonly flags?: RoomFlag[] readonly comments?: string readonly size: number - readonly members?: MemberDto[] + readonly occupants?: MemberDto[] } export class RoomSrvAppError extends AppError> { @@ -172,22 +182,31 @@ export const roomCountdownCheck = () => apiCall({ * * For hotel style conventions, if this succeeds, this means the attendee has just reserved a room, * and the room price has been added to their invoice. They may invite others to their room, up to the - * room capacity, and thus share the cost. + * room capacity, and thus share the cost. (Note: hotel style not implemented yet) * * Each attendee can be a member of at most one group, and they lose it if cancelled, e.g. due to failure to pay. * * 201: success. * * 400: This indicates a bug in this app because any validation errors should have been caught during field validation. - * The RoomErrorDto's details field will contain english language messages that describe the error in detail. + * The RoomErrorDto's details field will contain English language messages that describe the error in detail. * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. * * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. * + * 403: The user does not have permission to create groups (not a valid registration? not in valid status for creating a group?). + * In most cases, this indicates a bug in this app because validation and previous checks should never have let the user + * attempt to create a group. + * The RoomErrorDto's details field will contain an English language message that describes the error in detail. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + * * 409: Duplicate (same group name), or this user already is in a group (use /groups/my to check). * * 500: It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. * + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + * * This endpoint is optimized in the backend for high traffic, so it is safe to call during initial room booking. */ // why is this necessary? How does this differ from the stuff in attsrv.ts?!? @@ -199,14 +218,24 @@ export const createNewGroup = (group: GroupCreateDto) => apiCall({ }) /* - * GET /groups/my obtains the group the current user belongs to. + * GET /groups/my obtains the group the current user belongs to or has been invited to. + * + * Returns GroupDto and status 200, or RoomErrorDto and 401, 403, 404, 500, 502. * - * Returns GroupDto and status 200, or RoomErrorDto and 401, 403, 404, 500. + * This endpoint works the same for admins and ordinary users, even for admins it will require them to have a valid registration in attending status. + * This allows reg-frontend to be used by admins for managing their own group exactly as an ordinary user would. + * + * Rules for field visibility (apply even to admin users for this endpoint): + * Group owner: can see all fields. + * Group members: can see all other members of the group with full information, but no invites. + * Invited: can see only their invite record, but no other invites, and no group members. The group comment is hidden. * * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. - * 403: This user does not have a valid registration and thus is not eligible for groups. + * 403: This user does not have a valid registration or not in attending status and thus is not eligible for groups. * 404: This user, while eligible, is not currently in a group. * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. */ export const findMyGroup = () => apiCall({ path: '/groups/my', @@ -216,11 +245,24 @@ export const findMyGroup = () => apiCall({ /* * GET /groups?show=public lists public groups, which may be available to request joining. * - * Returns GroupListDto and status 200, or RoomErrorDto and 401, 403, 500. + * Returns GroupListDto and status 200, or RoomErrorDto and 401, 403, 404, 500, 502. + * + * Because of the show=public parameter, this endpoint works the same for admins and ordinary users, + * even for admins it will require them to have a valid registration in attending status. + * This allows reg-frontend to be used by admins for managing their own group exactly as an ordinary user would. + * + * Rules for field visibility (apply even to admin users): + * Group owner: can see all fields. + * Group members: can see all other members of the group with full information, but no invites. + * Invited: can see only their invite record, but no other invites, and no group members. The group comment is hidden. + * All others can see neither members nor invites. The group comment is hidden. * * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. * 403: This user does not have a valid registration and thus may not list groups (bars e.g. cancelled regs from viewing public groups) + * 404: You do not have a valid registration, and so cannot see the list of groups. * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. */ export const findPublicGroups = () => apiCall({ path: '/groups?show=public', @@ -230,13 +272,19 @@ export const findPublicGroups = () => apiCall({ /* * GET /groups/{uuid} reads a specific group. * - * Returns GroupDto and status 200, or RoomErrorDto and 400, 401, 403, 404, 500. + * Returns GroupDto and status 200, or RoomErrorDto and 400, 401, 403, 404, 500, 502. + * + * Note that for obtaining the group a user is in or has already been invited to, you should really use the /groups/my endpoint. + * + * This is mainly useful for reading group information when a user uses an invitation link created by the group owner. * * 400: the uuid was not valid * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. * 403: This user does not have access to this group. * 404: No such group. * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. */ export const getGroup = (uuid: string) => apiCall({ path: `/groups/${uuid}`, @@ -248,7 +296,9 @@ export const getGroup = (uuid: string) => apiCall({ * * Note that you cannot use this to change the group members! * - * Returns status 204, or RoomErrorDto and 400, 401, 403, 404, 409, 500. + * Admins or the current group owner can change the group owner to any member of the group. + * + * Returns status 204, or RoomErrorDto and 400, 401, 403, 404, 409, 500, 502. * * 400: the uuid or request body was not valid, or you tried to make changes to the group members. * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. @@ -256,6 +306,8 @@ export const getGroup = (uuid: string) => apiCall({ * 404: No such group. * 409: Your changes would turn this group into a duplicate (for example same name as existing other group) * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. */ // why is this necessary? How does this differ from the stuff in attsrv.ts?!? // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types @@ -268,18 +320,22 @@ export const updateGroup = (uuid: string, group: GroupDto) => apiCall({ /* * DELETE /groups/{uuid} deletes a group. * - * Note that you cannot use this while there are still any group members other than the owner! You must kick them out first. + * Disband (and delete) an existing group by uuid. Only the current owner or an admin can do this. + * + * Deleting a group will first kick everyone from the group! Deleting a group will also automatically expire all pending invites. * - * Deleting a group will automatically expire all pending invites. + * Note that it may not be the best user experience to allow using this while there are still any group members other than the owner! + * You should probably make the owner kick them out first. Or ask confirmation really strongly emphasizing the fact everyone will get kicked. * - * Returns status 204, or RoomErrorDto and 400, 401, 403, 404, 409, 500. + * Returns status 204, or RoomErrorDto and 400, 401, 403, 404, 409, 500, 502. * * 400: the uuid was not valid. * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. * 403: This user does not have permission to delete this group (not its owner and not an admin). * 404: No such group. - * 409: There were still other people in the group. Must remove them first so only the owner remains. * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. */ export const deleteGroup = (uuid: string) => apiCall({ path: `/groups/${uuid}`, @@ -295,19 +351,12 @@ export const deleteGroup = (uuid: string) => apiCall({ * * Only attending attendees can be added to a group or invited to groups. * - * Attendees cannot be in more than one group. Attendees who are members of any group cannot be invited to any group. - * - * The moment an attendee becomes a member of any group, any existing invitations for that attendee are discarded. - * - * For a single group, there can be only one invitation for each attendee. + * Attendees cannot be in or invited to more than one group. Attendees who are members of any group cannot be invited to any group. * * The total number of invitations plus members of a group cannot exceed its size. Example: If your * group has 2 members, and maximum group size is 4, you can invite at most 2 attendees. This is to prevent * invitation spam. * - * If an attendee is already in a group, or has already been individually assigned to a room, then they - * cannot be added to a group any more. - * * **Case 1: Owner invites first** * * The owner may use this to make an invitation to their group. This will create an invitation, including a code that @@ -344,8 +393,10 @@ export const deleteGroup = (uuid: string) => apiCall({ * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. * 403: Permission denied. * 404: Attendee or group not found, or wrong nickname, or wrong invitation code, or not a public group. - * 409: Duplicate assignment, or this attendee is already in another group, or has been individually assigned to a room. + * 409: Duplicate assignment, or this attendee is already in another group, or has been invited to another group. * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. */ export const joinOrInviteToGroup = (uuid: string, badgenumber: number, nickname?: string, code?: string) => apiCall({ path: `/groups/${uuid}/members/${badgenumber}?nickname=${nickname}&code=${code}`, @@ -380,10 +431,12 @@ export const joinOrInviteToGroup = (uuid: string, badgenumber: number, nickname? * 204: success * 400: invalid group id or badge number supplied * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. - * 403: Permission denied. + * 403: Permission denied. Will happen for example if you are neither the group owner nor the user with badgenumber. * 404: Attendee or group not found, or not a member. * 409: Conflict, this attendee is currently the owner of the group. Either change the owner first, or disband (delete) the group completely after kicking out everyone else. * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. */ export const kickOrDeclineFromGroup = (uuid: string, badgenumber: number, autodeny?: boolean) => apiCall({ path: `/groups/${uuid}/members/${badgenumber}?autodeny=${autodeny}`, @@ -396,14 +449,18 @@ export const kickOrDeclineFromGroup = (uuid: string, badgenumber: number, autode * Visibility of this information depends on the "final" flag that is set on the room, so admins can start planning * room assignments without them becoming immediately visible to users. * - * This endpoint works even for admins, giving them the room they are in. + * This endpoint works even for admins, giving them the room they are in. It treats admins exactly the same as regular users, + * so admins can be shown their assigned room with the same user experience as a regular user. * - * Returns RoomDto and status 200, or RoomErrorDto and 401, 403, 404, 500. + * Returns RoomDto and status 200, or RoomErrorDto and 401, 403, 404, 500, 502. * * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. * 403: The user does not have permission to see their room (maybe not an active registration?) - * 404: You are not in any rooms (that are visible to you). Note that this may happen even if the attendee actually is in a room, but the room isn't flagged as "final". This is to prevent showing premature (wrong) room information while the admins are still planning room assignments. + * 404: You are not in any rooms (that are visible to you). Note that this may happen even if the attendee actually is in a room, + * but the room isn't flagged as "final". This is to prevent showing premature (wrong) room information while the admins are still planning room assignments. * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. */ export const findMyRoom = () => apiCall({ path: '/rooms/my', From 752b0de4c17b856a735c77ec89a09e96a5c1c3ff Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 9 Nov 2024 16:46:51 +0100 Subject: [PATCH 6/7] Fix load error --- .../funnels/funnels/register/steps/roomshare.tsx | 3 --- .../funnels/funnels/register/steps/summary.tsx | 2 ++ src/config.ts | 2 +- src/navigation/router.tsx | 16 +++++++++++----- src/pages/room-sharing.tsx | 14 ++++++++++++++ 5 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 src/pages/room-sharing.tsx diff --git a/src/components/funnels/funnels/register/steps/roomshare.tsx b/src/components/funnels/funnels/register/steps/roomshare.tsx index c1f9fa4..24a62ec 100644 --- a/src/components/funnels/funnels/register/steps/roomshare.tsx +++ b/src/components/funnels/funnels/register/steps/roomshare.tsx @@ -8,9 +8,6 @@ import RoomShareCreate from '~/components/funnels/funnels/register/steps/roomsha const RoomShare = (_: ReadonlyRouteComponentProps) => - - ` - export default RoomShare diff --git a/src/components/funnels/funnels/register/steps/summary.tsx b/src/components/funnels/funnels/register/steps/summary.tsx index 793fce8..5c8f501 100644 --- a/src/components/funnels/funnels/register/steps/summary.tsx +++ b/src/components/funnels/funnels/register/steps/summary.tsx @@ -147,6 +147,8 @@ const Summary = (_: ReadonlyRouteComponentProps) => { return

Your registration

+ {config.enableRoomshare ? Room share : undefined} + We have received your registration and will confirm it when things are ready. Keep an eye on your mailbox! diff --git a/src/config.ts b/src/config.ts index b141686..38d3b20 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,7 +25,7 @@ const config = checkConfig({ dayTicketEndDate: DateTime.fromISO('2024-09-21', { zone: 'Europe/Berlin' }), earliestBirthDate: DateTime.fromISO('1901-01-01'), minimumAge: 18, - enableRoomshare: true, // For development + enableRoomshare: true, // TODO: For development allowedCountries: ['AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AC', 'AU', 'AT', 'AZ', 'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR', 'IO', 'BN', 'BG', 'BF', 'BI', 'CV', 'KH', 'CM', 'CA', 'KY', 'CF', 'EA', 'TD', 'CL', 'CN', 'CX', 'CP', 'CC', 'CO', 'KM', 'CG', 'CD', 'CK', 'CR', 'HR', 'CU', 'CW', 'CY', 'CZ', 'CI', 'DK', 'DG', 'DJ', 'DM', 'DO', 'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'SZ', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF', 'GA', 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY', 'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', 'JM', 'JP', 'JE', 'JO', 'IC', 'KZ', 'KE', 'KI', 'KP', 'KR', 'KW', 'KG', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY', 'LI', 'LT', 'LU', 'MO', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'MX', 'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'MM', 'NA', 'NR', 'NP', 'NL', 'NC', 'NZ', 'NI', 'NE', 'NG', 'NU', 'NF', 'MK', 'MP', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH', 'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', 'BL', 'SH', 'KN', 'LC', 'MF', 'PM', 'VC', 'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SX', 'SK', 'SI', 'SB', 'SO', 'ZA', 'GS', 'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SE', 'CH', 'SY', 'TW', 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK', 'TO', 'TT', 'TA', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'UM', 'US', 'UY', 'UZ', 'VU', 'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW'], ticketLevels: { 'standard': { diff --git a/src/navigation/router.tsx b/src/navigation/router.tsx index ddba477..d5377e6 100644 --- a/src/navigation/router.tsx +++ b/src/navigation/router.tsx @@ -17,23 +17,21 @@ import { withPrefix } from 'gatsby' import { useAppSelector } from '~/hooks/redux' import { isEditMode } from '~/state/selectors/register' import config from '~/config' +import RoomShareHome from '~/components/funnels/funnels/register/steps/roomshare/home' +import RoomShareCreate from '~/components/funnels/funnels/register/steps/roomshare/create' +import RoomShareJoin from '~/components/funnels/funnels/register/steps/roomshare/join' export const EFRouter = () => -const RoomShareRoutes = () => - - export const RegisterRouter = () => { const isEdit = useAppSelector(isEditMode()) - const enableRoomShare = config.enableRoomshare return - {enableRoomShare && } @@ -46,3 +44,11 @@ export const HotelBookingRouter = () => + +export const RoomShareRouter = () => { + return + + ` + + +} diff --git a/src/pages/room-sharing.tsx b/src/pages/room-sharing.tsx new file mode 100644 index 0000000..b753424 --- /dev/null +++ b/src/pages/room-sharing.tsx @@ -0,0 +1,14 @@ +import { RoomShareRouter } from '~/navigation/router' + +import SEO from '~/components/seo' +import { ReadonlyRouteComponentProps } from '~/util/readonly-types' +import Layout from '~/components/layout' +import config from '~/config' + +export const Head = () => + +const RoomSharingPage = (_: ReadonlyRouteComponentProps) => + {RoomShareRouter()} + + +export default RoomSharingPage From 22d77aed1848a06ceb0402f5170056186e0586db Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Mon, 18 Nov 2024 22:44:06 +0100 Subject: [PATCH 7/7] Current room share info --- .../funnels/register/steps/roomshare.tsx | 6 +-- .../funnels/register/steps/summary.tsx | 44 ++++++++++++++++--- .../{register/steps => }/roomshare/create.tsx | 0 .../{register/steps => }/roomshare/home.tsx | 2 +- .../{register/steps => }/roomshare/join.tsx | 0 src/navigation/router.tsx | 17 ++++--- .../{room-sharing.tsx => room-share.tsx} | 0 src/state/actions/index.ts | 3 ++ src/state/actions/room-sharing.ts | 7 +++ src/state/epics/index.ts | 2 + src/state/epics/room-sharing.ts | 27 ++++++++++++ src/state/models/errors.ts | 1 + src/state/reducers/index.ts | 3 ++ src/state/reducers/room-sharing.ts | 20 +++++++++ src/state/selectors/room-sharing.ts | 3 ++ 15 files changed, 116 insertions(+), 19 deletions(-) rename src/components/funnels/funnels/{register/steps => }/roomshare/create.tsx (100%) rename src/components/funnels/funnels/{register/steps => }/roomshare/home.tsx (88%) rename src/components/funnels/funnels/{register/steps => }/roomshare/join.tsx (100%) rename src/pages/{room-sharing.tsx => room-share.tsx} (100%) create mode 100644 src/state/actions/room-sharing.ts create mode 100644 src/state/epics/room-sharing.ts create mode 100644 src/state/reducers/room-sharing.ts create mode 100644 src/state/selectors/room-sharing.ts diff --git a/src/components/funnels/funnels/register/steps/roomshare.tsx b/src/components/funnels/funnels/register/steps/roomshare.tsx index 24a62ec..d773378 100644 --- a/src/components/funnels/funnels/register/steps/roomshare.tsx +++ b/src/components/funnels/funnels/register/steps/roomshare.tsx @@ -2,9 +2,9 @@ import { ReadonlyRouteComponentProps } from '~/util/readonly-types' import { Router } from '@reach/router' import { withPrefix } from 'gatsby' import * as ROUTES from '~/navigation/routes' -import RoomShareJoin from '~/components/funnels/funnels/register/steps/roomshare/join' -import RoomShareHome from '~/components/funnels/funnels/register/steps/roomshare/home' -import RoomShareCreate from '~/components/funnels/funnels/register/steps/roomshare/create' +import RoomShareJoin from '~/components/funnels/funnels/roomshare/join' +import RoomShareHome from '~/components/funnels/funnels/roomshare/home' +import RoomShareCreate from '~/components/funnels/funnels/roomshare/create' const RoomShare = (_: ReadonlyRouteComponentProps) => diff --git a/src/components/funnels/funnels/register/steps/summary.tsx b/src/components/funnels/funnels/register/steps/summary.tsx index 5c8f501..00922f0 100644 --- a/src/components/funnels/funnels/register/steps/summary.tsx +++ b/src/components/funnels/funnels/register/steps/summary.tsx @@ -12,17 +12,24 @@ import { useCurrentLocale } from '~/localization' import { useFunnelForm } from '~/hooks/funnels/form' import { Checkbox, ErrorMessage, Form } from '@eurofurence/reg-component-library' import config from '~/config' +import { getRoomGroup } from '~/state/selectors/room-sharing' +import { GroupDto } from '~/apis/roomsrv' +import { is } from 'ramda' +import room from '~/components/funnels/funnels/hotel-booking/steps/room' interface PropertyDefinition { readonly id: string readonly value: string readonly wide?: boolean + readonly subvalue?: string } interface SectionProps { readonly id: string readonly editLink: string readonly properties: readonly PropertyDefinition[] + readonly editText?: string + readonly showEditLink?: boolean } const SectionContainer = styled.section<{ readonly status: RegistrationStatus }>` @@ -100,7 +107,6 @@ const RegistrationId = styled.p` &:not(:first-child) { margin-top: 2em; } -} ` const TermsForm = styled(Form)` @@ -111,21 +117,40 @@ const StatusText = styled.p<{ readonly status: RegistrationStatus }>` color: ${({ status }) => status === 'cancelled' ? 'var(--color-semantic-error)' : 'unset'}; ` -const Section = ({ id: sectionId, editLink, properties }: SectionProps) => { +const Section = ({ id: sectionId, editLink, properties, editText, showEditLink }: SectionProps) => { const status = useAppSelector(getStatus())! + const editTextStr = editText ?? 'Edit information' return {sectionId} - {status === 'cancelled' ? undefined : Edit information} + {status === 'cancelled' || showEditLink === false ? undefined : {editTextStr}} - {properties.map(({ id, value, wide = false }) => + {properties.map(({ id, value, subvalue, wide = false }) => {id} {value} + {subvalue} )} } +const getRoomShareSectionProps = (isAttending: boolean, roomShare: GroupDto | null) => { + if (isAttending && roomShare) { + return [ + { id: 'room-share-group-name', value: roomShare.name }, + { id: 'room-share-members', value: roomShare.members.map(member => member.nickname).join('\n') }, + ] + } + + if (isAttending && !roomShare) { + return [{ id: '', value: 'No group', subvalue: 'You can create or join one on the room sharing page' }] + } + + if (!isAttending && !roomShare) { + return [{ id: '', value: 'No group', subvalue: 'Your registration needs to be approved by us first' }] + } +} + // eslint-disable-next-line max-statements const Summary = (_: ReadonlyRouteComponentProps) => { const registrationId = useAppSelector(getRegistrationId())! @@ -134,6 +159,10 @@ const Summary = (_: ReadonlyRouteComponentProps) => { const optionalInfo = useAppSelector(getOptionalInfo())! const isEdit = useAppSelector(isEditMode()) const status = useAppSelector(getStatus())! + const isAttendingStatus = ['approved', 'partially-paid', 'paid', 'checked-in'].includes(status) + + const roomShare = useAppSelector(getRoomGroup()) + const roomShareSectionProps = getRoomShareSectionProps(isAttendingStatus, roomShare) const locale = useCurrentLocale() const { l10n } = useLocalization() const { handleSubmit, register, formState: { errors } } = useFunnelForm('register-summary') @@ -147,8 +176,6 @@ const Summary = (_: ReadonlyRouteComponentProps) => { return

Your registration

- {config.enableRoomshare ? Room share : undefined} - We have received your registration and will confirm it when things are ready. Keep an eye on your mailbox! @@ -157,6 +184,11 @@ const Summary = (_: ReadonlyRouteComponentProps) => { Badge number: {registrationId} : undefined } + {/*TODO: Localize editText*/} + {config.enableRoomshare + ?
+ : undefined} +
{ return (
-

RoomShareHome

+

Roomsharing

) } diff --git a/src/components/funnels/funnels/register/steps/roomshare/join.tsx b/src/components/funnels/funnels/roomshare/join.tsx similarity index 100% rename from src/components/funnels/funnels/register/steps/roomshare/join.tsx rename to src/components/funnels/funnels/roomshare/join.tsx diff --git a/src/navigation/router.tsx b/src/navigation/router.tsx index d5377e6..9796d95 100644 --- a/src/navigation/router.tsx +++ b/src/navigation/router.tsx @@ -10,16 +10,15 @@ import Room from '~/components/funnels/funnels/hotel-booking/steps/room' import Guests from '~/components/funnels/funnels/hotel-booking/steps/guests' import AdditionalInfo from '~/components/funnels/funnels/hotel-booking/steps/additional-info' import Email from '~/components/funnels/funnels/hotel-booking/steps/email' -import RoomShare from '~/components/funnels/funnels/register/steps/roomshare' import * as ROUTES from './routes' import { withPrefix } from 'gatsby' import { useAppSelector } from '~/hooks/redux' import { isEditMode } from '~/state/selectors/register' -import config from '~/config' -import RoomShareHome from '~/components/funnels/funnels/register/steps/roomshare/home' -import RoomShareCreate from '~/components/funnels/funnels/register/steps/roomshare/create' -import RoomShareJoin from '~/components/funnels/funnels/register/steps/roomshare/join' +import RoomShareHome from '~/components/funnels/funnels/roomshare/home' +import RoomShareCreate from '~/components/funnels/funnels/roomshare/create' +import RoomShareJoin from '~/components/funnels/funnels/roomshare/join' +import { REGISTER_ROOM_SHARE } from './routes' export const EFRouter = () => @@ -46,9 +45,9 @@ export const HotelBookingRouter = () => export const RoomShareRouter = () => { - return - - ` - + return + + + } diff --git a/src/pages/room-sharing.tsx b/src/pages/room-share.tsx similarity index 100% rename from src/pages/room-sharing.tsx rename to src/pages/room-share.tsx diff --git a/src/state/actions/index.ts b/src/state/actions/index.ts index 62cd86f..cef1daf 100644 --- a/src/state/actions/index.ts +++ b/src/state/actions/index.ts @@ -4,6 +4,7 @@ import { ErrorAction } from './errors' import { FormAction } from './forms' import { RegisterAction } from './register' import { NavigationAction } from './navigation' +import { RoomSharingAction } from './room-sharing' export type { GetAction } from './create-action' @@ -14,3 +15,5 @@ export type AnyAppAction = | FormAction | RegisterAction | NavigationAction + | RoomSharingAction + // Add new action types here diff --git a/src/state/actions/room-sharing.ts b/src/state/actions/room-sharing.ts new file mode 100644 index 0000000..fee5626 --- /dev/null +++ b/src/state/actions/room-sharing.ts @@ -0,0 +1,7 @@ +import { createAction } from './create-action' +import { GroupDto } from '~/apis/roomsrv' + +export const LoadRoomShareState = createAction('[RoomSharing] Load room share state') + +export type RoomSharingAction + = typeof LoadRoomShareState diff --git a/src/state/epics/index.ts b/src/state/epics/index.ts index 9ce44bb..5a6c9db 100644 --- a/src/state/epics/index.ts +++ b/src/state/epics/index.ts @@ -5,6 +5,7 @@ import autosave from './autosave' import register from './register' import hotelBooking from './hotel-booking' import navigation from './navigation' +import roomSharing from './room-sharing' export default combineEpics( auth, @@ -12,4 +13,5 @@ export default combineEpics( register, hotelBooking, navigation, + roomSharing, ) diff --git a/src/state/epics/room-sharing.ts b/src/state/epics/room-sharing.ts new file mode 100644 index 0000000..f76e9ce --- /dev/null +++ b/src/state/epics/room-sharing.ts @@ -0,0 +1,27 @@ +import { findMyGroup } from '~/apis/roomsrv' +import { concatMap, of } from 'rxjs' +import { LoadRoomShareState } from '~/state/actions/room-sharing' +import { catchAppError } from '~/state/epics/operators/catch-app-error' +import { combineEpics } from 'redux-observable' +import { AnyAppAction, GetAction } from '~/state/actions' +import { AppState } from '~/state' +import { catchError } from 'rxjs/operators' + +const loadNoGroup = () => of(LoadRoomShareState.create(null)) + +const loadMyGroup = () => findMyGroup().pipe( + concatMap(resp => { + return of(LoadRoomShareState.create(resp.response)) + }), + catchError(error => { + // eslint-disable-next-line no-console + console.error('Failed to load room share state', error) + + return loadNoGroup() + }), + catchAppError('room-share-load'), +) + +export default combineEpics, GetAction, AppState>( + loadMyGroup, +) diff --git a/src/state/models/errors.ts b/src/state/models/errors.ts index 2551818..67d0fe1 100644 --- a/src/state/models/errors.ts +++ b/src/state/models/errors.ts @@ -9,6 +9,7 @@ export type AppErrorOperation = | 'registration-initiate-payment' | 'registration-set-locale' | 'user-info-lookup' + | 'room-share-load' | 'unknown' export interface ErrorReport { diff --git a/src/state/reducers/index.ts b/src/state/reducers/index.ts index 43d91b3..e6baa3c 100644 --- a/src/state/reducers/index.ts +++ b/src/state/reducers/index.ts @@ -5,6 +5,7 @@ import auth from './auth' import errors from './errors' import register from './register' import hotelBooking from './hotel-booking' +import roomSharing from './room-sharing' export default combineReducers<{ readonly autosave: typeof autosave @@ -12,10 +13,12 @@ export default combineReducers<{ readonly errors: typeof errors readonly register: typeof register readonly hotelBooking: typeof hotelBooking + readonly roomSharing: typeof roomSharing }>({ autosave, auth, errors, register, hotelBooking, + roomSharing, }) diff --git a/src/state/reducers/room-sharing.ts b/src/state/reducers/room-sharing.ts new file mode 100644 index 0000000..13e8315 --- /dev/null +++ b/src/state/reducers/room-sharing.ts @@ -0,0 +1,20 @@ +import { GroupDto } from '~/apis/roomsrv' +import { AnyAppAction, GetAction } from '~/state/actions' +import { LoadRoomShareState } from '~/state/actions/room-sharing' + +export interface RoomSharingState { + readonly roomShare: GroupDto | null +} + +const defaultState: RoomSharingState = { + roomShare: null, +} + +export default (state: RoomSharingState = defaultState, action: GetAction): RoomSharingState => { + switch (action.type) { + case LoadRoomShareState.type: + return { ...state, roomShare: action.payload } + default: + return state + } +} diff --git a/src/state/selectors/room-sharing.ts b/src/state/selectors/room-sharing.ts new file mode 100644 index 0000000..7a9b5b7 --- /dev/null +++ b/src/state/selectors/room-sharing.ts @@ -0,0 +1,3 @@ +import { AppState } from '~/state' + +export const getRoomGroup = () => (s: AppState) => s.roomSharing.roomShare