mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 12:16:12 -05:00
Team events: select members & allow editing
This commit is contained in:
@@ -1384,6 +1384,12 @@ export interface TeamEvent {
|
||||
createdAt: Generated<number>;
|
||||
}
|
||||
|
||||
/** Participant of a team event limited to selected members. No rows for an event = the whole team takes part. */
|
||||
export interface TeamEventMember {
|
||||
teamEventId: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export type Tables = { [P in keyof DB]: Selectable<DB[P]> };
|
||||
export type TablesInsertable = { [P in keyof DB]: Insertable<DB[P]> };
|
||||
|
||||
@@ -1538,4 +1544,5 @@ export interface DB {
|
||||
AvailabilitySlot: AvailabilitySlot;
|
||||
AvailabilityDayNote: AvailabilityDayNote;
|
||||
TeamEvent: TeamEvent;
|
||||
TeamEventMember: TeamEventMember;
|
||||
}
|
||||
|
||||
@@ -394,6 +394,82 @@ describe("AvailabilityRepository.findTeamEventsByTeamId", () => {
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].name).toBe("VoD review");
|
||||
});
|
||||
|
||||
test("returns the participants of an event limited to selected members, empty for a whole-team event", async () => {
|
||||
const team = await TeamFactory.create({
|
||||
name: "Alpha",
|
||||
memberUserIds: [users.id(1), users.id(2)],
|
||||
});
|
||||
|
||||
await TeamEventFactory.create({
|
||||
teamId: team.id,
|
||||
authorId: users.id(1),
|
||||
name: "Whole team",
|
||||
startsAt: at("2026-08-25", "20:00"),
|
||||
endsAt: at("2026-08-25", "21:00"),
|
||||
});
|
||||
await TeamEventFactory.create({
|
||||
teamId: team.id,
|
||||
authorId: users.id(1),
|
||||
name: "Selected only",
|
||||
startsAt: at("2026-08-26", "20:00"),
|
||||
endsAt: at("2026-08-26", "21:00"),
|
||||
participantUserIds: [users.id(2)],
|
||||
});
|
||||
|
||||
const events = await AvailabilityRepository.findTeamEventsByTeamId({
|
||||
teamId: team.id,
|
||||
...WINDOW,
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].participants).toEqual([]);
|
||||
expect(events[1].participants).toEqual([{ userId: users.id(2) }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvailabilityRepository.findAllTeamEventsByUserIds", () => {
|
||||
beforeEach(async () => {
|
||||
await users.create(2);
|
||||
});
|
||||
|
||||
test("an event limited to selected members produces rows only for them", async () => {
|
||||
const team = await TeamFactory.create({
|
||||
name: "Alpha",
|
||||
memberUserIds: [users.id(1), users.id(2)],
|
||||
});
|
||||
|
||||
await TeamEventFactory.create({
|
||||
teamId: team.id,
|
||||
authorId: users.id(1),
|
||||
name: "Whole team",
|
||||
startsAt: at("2026-08-25", "20:00"),
|
||||
endsAt: at("2026-08-25", "21:00"),
|
||||
});
|
||||
await TeamEventFactory.create({
|
||||
teamId: team.id,
|
||||
authorId: users.id(1),
|
||||
name: "Selected only",
|
||||
startsAt: at("2026-08-26", "20:00"),
|
||||
endsAt: at("2026-08-26", "21:00"),
|
||||
participantUserIds: [users.id(2)],
|
||||
});
|
||||
|
||||
const events = await AvailabilityRepository.findAllTeamEventsByUserIds({
|
||||
userIds: [users.id(1), users.id(2)],
|
||||
...WINDOW,
|
||||
});
|
||||
|
||||
expect(
|
||||
events.filter((event) => event.userId === users.id(1)).map((e) => e.name),
|
||||
).toEqual(["Whole team"]);
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.userId === users.id(2))
|
||||
.map((e) => e.name)
|
||||
.sort(),
|
||||
).toEqual(["Selected only", "Whole team"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvailabilityRepository.findAllUpcomingTeamEventsByUserId", () => {
|
||||
@@ -461,6 +537,104 @@ describe("AvailabilityRepository.findAllUpcomingTeamEventsByUserId", () => {
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaves out an event limited to selected members the user is not one of", async () => {
|
||||
const team = await TeamFactory.create({
|
||||
name: "Alpha",
|
||||
memberUserIds: [users.id(1), users.id(2)],
|
||||
});
|
||||
|
||||
await TeamEventFactory.create({
|
||||
teamId: team.id,
|
||||
authorId: users.id(1),
|
||||
name: "Selected only",
|
||||
startsAt: at("2026-08-25", "20:00"),
|
||||
endsAt: at("2026-08-25", "21:00"),
|
||||
participantUserIds: [users.id(2)],
|
||||
});
|
||||
|
||||
expect(
|
||||
await AvailabilityRepository.findAllUpcomingTeamEventsByUserId({
|
||||
userId: users.id(1),
|
||||
...WINDOW,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await AvailabilityRepository.findAllUpcomingTeamEventsByUserId({
|
||||
userId: users.id(2),
|
||||
...WINDOW,
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvailabilityRepository.updateTeamEvent", () => {
|
||||
beforeEach(async () => {
|
||||
await users.create(2);
|
||||
});
|
||||
|
||||
test("updates the event and replaces its participant limitation", async () => {
|
||||
const team = await TeamFactory.create({
|
||||
name: "Alpha",
|
||||
memberUserIds: [users.id(1), users.id(2)],
|
||||
});
|
||||
const event = await TeamEventFactory.create({
|
||||
teamId: team.id,
|
||||
authorId: users.id(1),
|
||||
name: "VoD review",
|
||||
startsAt: at("2026-08-25", "20:00"),
|
||||
endsAt: at("2026-08-25", "21:00"),
|
||||
});
|
||||
|
||||
await AvailabilityRepository.updateTeamEvent({
|
||||
id: event.id,
|
||||
name: "Scrim review",
|
||||
startsAt: at("2026-08-26", "19:00"),
|
||||
endsAt: at("2026-08-26", "20:30"),
|
||||
participantUserIds: [users.id(2)],
|
||||
});
|
||||
|
||||
const [updated] = await AvailabilityRepository.findTeamEventsByTeamId({
|
||||
teamId: team.id,
|
||||
...WINDOW,
|
||||
});
|
||||
|
||||
expect(updated).toMatchObject({
|
||||
name: "Scrim review",
|
||||
startsAt: at("2026-08-26", "19:00"),
|
||||
endsAt: at("2026-08-26", "20:30"),
|
||||
participants: [{ userId: users.id(2) }],
|
||||
});
|
||||
});
|
||||
|
||||
test("clears the participant limitation when updated back to the whole team", async () => {
|
||||
const team = await TeamFactory.create({
|
||||
name: "Alpha",
|
||||
memberUserIds: [users.id(1), users.id(2)],
|
||||
});
|
||||
const event = await TeamEventFactory.create({
|
||||
teamId: team.id,
|
||||
authorId: users.id(1),
|
||||
name: "VoD review",
|
||||
startsAt: at("2026-08-25", "20:00"),
|
||||
endsAt: at("2026-08-25", "21:00"),
|
||||
participantUserIds: [users.id(2)],
|
||||
});
|
||||
|
||||
await AvailabilityRepository.updateTeamEvent({
|
||||
id: event.id,
|
||||
name: "VoD review",
|
||||
startsAt: at("2026-08-25", "20:00"),
|
||||
endsAt: at("2026-08-25", "21:00"),
|
||||
});
|
||||
|
||||
const [updated] = await AvailabilityRepository.findTeamEventsByTeamId({
|
||||
teamId: team.id,
|
||||
...WINDOW,
|
||||
});
|
||||
|
||||
expect(updated.participants).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvailabilityRepository.deleteTeamEvent", () => {
|
||||
|
||||
@@ -150,7 +150,8 @@ export async function findWeekReminderUserIds(weekStartsAt: number) {
|
||||
|
||||
/**
|
||||
* Team events of every team the given users are members of (secondary teams
|
||||
* included) that overlap the given window, one row per member.
|
||||
* included) that overlap the given window, one row per member. Events limited
|
||||
* to selected participants only produce rows for those participants.
|
||||
*/
|
||||
export function findAllTeamEventsByUserIds({
|
||||
userIds,
|
||||
@@ -179,10 +180,27 @@ export function findAllTeamEventsByUserIds({
|
||||
.where("TeamMemberWithSecondary.userId", "in", userIds)
|
||||
.where("TeamEvent.startsAt", "<", endsAt)
|
||||
.where("TeamEvent.endsAt", ">", startsAt)
|
||||
.where((eb) => {
|
||||
const participants = eb
|
||||
.selectFrom("TeamEventMember")
|
||||
.select("TeamEventMember.userId")
|
||||
.whereRef("TeamEventMember.teamEventId", "=", "TeamEvent.id");
|
||||
|
||||
return eb.or([
|
||||
eb.not(eb.exists(participants)),
|
||||
eb.exists(
|
||||
participants.whereRef(
|
||||
"TeamEventMember.userId",
|
||||
"=",
|
||||
"TeamMemberWithSecondary.userId",
|
||||
),
|
||||
),
|
||||
]);
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Team events of one team overlapping the given window. */
|
||||
/** Team events of one team overlapping the given window, with the participant user ids of events limited to selected members (empty = the whole team). */
|
||||
export function findTeamEventsByTeamId({
|
||||
teamId,
|
||||
startsAt,
|
||||
@@ -194,11 +212,18 @@ export function findTeamEventsByTeamId({
|
||||
}) {
|
||||
return db
|
||||
.selectFrom("TeamEvent")
|
||||
.select([
|
||||
.select((eb) => [
|
||||
"TeamEvent.id",
|
||||
"TeamEvent.name",
|
||||
"TeamEvent.startsAt",
|
||||
"TeamEvent.endsAt",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TeamEventMember")
|
||||
.select("TeamEventMember.userId")
|
||||
.whereRef("TeamEventMember.teamEventId", "=", "TeamEvent.id")
|
||||
.orderBy("TeamEventMember.userId", "asc"),
|
||||
).as("participants"),
|
||||
])
|
||||
.where("TeamEvent.teamId", "=", teamId)
|
||||
.where("TeamEvent.startsAt", "<", endsAt)
|
||||
@@ -210,7 +235,8 @@ export function findTeamEventsByTeamId({
|
||||
/**
|
||||
* Ongoing and upcoming team events of every team the given user is a member of
|
||||
* (secondary teams included), starting within the given window, with the
|
||||
* owning team attached. For the user's personal calendar surfaces.
|
||||
* owning team attached. Events limited to selected participants show up only
|
||||
* for those participants. For the user's personal calendar surfaces.
|
||||
*/
|
||||
export function findAllUpcomingTeamEventsByUserId({
|
||||
userId,
|
||||
@@ -244,6 +270,17 @@ export function findAllUpcomingTeamEventsByUserId({
|
||||
.where("TeamMemberWithSecondary.userId", "=", userId)
|
||||
.where("TeamEvent.endsAt", ">", startsAt)
|
||||
.where("TeamEvent.startsAt", "<", endsAt)
|
||||
.where((eb) => {
|
||||
const participants = eb
|
||||
.selectFrom("TeamEventMember")
|
||||
.select("TeamEventMember.userId")
|
||||
.whereRef("TeamEventMember.teamEventId", "=", "TeamEvent.id");
|
||||
|
||||
return eb.or([
|
||||
eb.not(eb.exists(participants)),
|
||||
eb.exists(participants.where("TeamEventMember.userId", "=", userId)),
|
||||
]);
|
||||
})
|
||||
.orderBy("TeamEvent.startsAt", "asc")
|
||||
.execute();
|
||||
}
|
||||
@@ -374,20 +411,75 @@ export function deleteTeamEventsEndedBefore(endsAt: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event the whole team takes part in. Author is the acting user.
|
||||
* Adds a team event. Author is the acting user. Without `participantUserIds`
|
||||
* the whole team takes part; with them only the given members do.
|
||||
*
|
||||
* @returns id of the new event
|
||||
*/
|
||||
export async function insertTeamEvent(
|
||||
args: Omit<TablesInsertable["TeamEvent"], "authorId">,
|
||||
) {
|
||||
const event = await db
|
||||
.insertInto("TeamEvent")
|
||||
.values({ ...args, authorId: actorId() })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
export function insertTeamEvent({
|
||||
participantUserIds,
|
||||
...args
|
||||
}: Omit<TablesInsertable["TeamEvent"], "authorId"> & {
|
||||
participantUserIds?: Array<number>;
|
||||
}) {
|
||||
const authorId = actorId();
|
||||
|
||||
return event.id;
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const event = await trx
|
||||
.insertInto("TeamEvent")
|
||||
.values({ ...args, authorId })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (participantUserIds && participantUserIds.length > 0) {
|
||||
await trx
|
||||
.insertInto("TeamEventMember")
|
||||
.values(
|
||||
participantUserIds.map((userId) => ({
|
||||
teamEventId: event.id,
|
||||
userId,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
return event.id;
|
||||
});
|
||||
}
|
||||
|
||||
/** Updates a team event, replacing its participant limitation (none = the whole team takes part). */
|
||||
export function updateTeamEvent({
|
||||
id,
|
||||
participantUserIds,
|
||||
...args
|
||||
}: {
|
||||
id: number;
|
||||
name: string;
|
||||
startsAt: number;
|
||||
endsAt: number;
|
||||
participantUserIds?: Array<number>;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.updateTable("TeamEvent")
|
||||
.set(args)
|
||||
.where("TeamEvent.id", "=", id)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.deleteFrom("TeamEventMember")
|
||||
.where("TeamEventMember.teamEventId", "=", id)
|
||||
.execute();
|
||||
|
||||
if (participantUserIds && participantUserIds.length > 0) {
|
||||
await trx
|
||||
.insertInto("TeamEventMember")
|
||||
.values(
|
||||
participantUserIds.map((userId) => ({ teamEventId: id, userId })),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTeamEvent(id: number) {
|
||||
|
||||
@@ -36,18 +36,23 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
switch (data._action) {
|
||||
case "ADD_EVENT": {
|
||||
const startsAt = dateToDatabaseTimestamp(data.startsAt);
|
||||
const participantUserIds = validatedParticipantUserIds(data, team);
|
||||
|
||||
await AvailabilityRepository.insertTeamEvent({
|
||||
teamId: team.id,
|
||||
name: data.name,
|
||||
startsAt,
|
||||
endsAt: startsAt + Number(data.duration) * 60,
|
||||
participantUserIds,
|
||||
});
|
||||
|
||||
await notify({
|
||||
userIds: team.members
|
||||
.filter(
|
||||
(member) => member.id !== user.id && member.role !== "CHEERLEADER",
|
||||
(member) =>
|
||||
member.id !== user.id &&
|
||||
member.role !== "CHEERLEADER" &&
|
||||
(!participantUserIds || participantUserIds.includes(member.id)),
|
||||
)
|
||||
.map((member) => member.id),
|
||||
notification: {
|
||||
@@ -63,6 +68,27 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
|
||||
return null;
|
||||
}
|
||||
case "EDIT_EVENT": {
|
||||
const event = notFoundIfNullish(
|
||||
await AvailabilityRepository.findTeamEventById(data.eventId),
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
event.teamId === team.id,
|
||||
"Event does not belong to the team",
|
||||
);
|
||||
|
||||
const startsAt = dateToDatabaseTimestamp(data.startsAt);
|
||||
|
||||
await AvailabilityRepository.updateTeamEvent({
|
||||
id: event.id,
|
||||
name: data.name,
|
||||
startsAt,
|
||||
endsAt: startsAt + Number(data.duration) * 60,
|
||||
participantUserIds: validatedParticipantUserIds(data, team),
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
case "DELETE_EVENT": {
|
||||
const event = notFoundIfNullish(
|
||||
await AvailabilityRepository.findTeamEventById(data.eventId),
|
||||
@@ -80,3 +106,25 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
};
|
||||
|
||||
function validatedParticipantUserIds(
|
||||
data: {
|
||||
participants: "ALL" | "SELECTED";
|
||||
participantUserIds: Array<string>;
|
||||
},
|
||||
team: NonNullable<Awaited<ReturnType<typeof TeamRepository.findByCustomUrl>>>,
|
||||
) {
|
||||
if (data.participants !== "SELECTED") return undefined;
|
||||
|
||||
const userIds = data.participantUserIds.map(Number);
|
||||
errorToastIfFalsy(
|
||||
userIds.every((userId) =>
|
||||
team.members.some(
|
||||
(member) => member.id === userId && member.role !== "CHEERLEADER",
|
||||
),
|
||||
),
|
||||
"Participants must be members of the team",
|
||||
);
|
||||
|
||||
return userIds;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { add, sub } from "date-fns";
|
||||
import { add, addWeeks, startOfWeek, sub } from "date-fns";
|
||||
import * as v from "valibot";
|
||||
import { datetime, select, stringConstant, textField } from "~/form/fields";
|
||||
import { _action, id } from "~/utils/schema";
|
||||
import {
|
||||
checkboxGroupDynamic,
|
||||
datetime,
|
||||
idConstant,
|
||||
radioGroup,
|
||||
select,
|
||||
stringConstant,
|
||||
textField,
|
||||
} from "~/form/fields";
|
||||
import { _action, id, superRefine, type ValidationCtx } from "~/utils/schema";
|
||||
import { AVAILABILITY } from "./availability-constants";
|
||||
|
||||
const DAY_MINUTES = 24 * 60;
|
||||
@@ -66,8 +74,7 @@ const teamEventDurationItems = [
|
||||
{ label: "options.duration.6h" as const, value: "360" },
|
||||
] as const;
|
||||
|
||||
export const addTeamEventSchema = v.object({
|
||||
_action: stringConstant("ADD_EVENT"),
|
||||
const teamEventFields = {
|
||||
name: textField({
|
||||
label: "labels.name",
|
||||
maxLength: AVAILABILITY.TEAM_EVENT_NAME_MAX_LENGTH,
|
||||
@@ -75,7 +82,16 @@ export const addTeamEventSchema = v.object({
|
||||
startsAt: datetime({
|
||||
label: "labels.start",
|
||||
min: () => sub(new Date(), { hours: 1 }),
|
||||
max: () => add(new Date(), { months: 2 }),
|
||||
// end of the next week, the furthest the schedule shows; the hours are
|
||||
// slack for server-side validation running in another timezone than the
|
||||
// viewer's week
|
||||
max: () =>
|
||||
add(
|
||||
startOfWeek(addWeeks(new Date(), AVAILABILITY.WEEK_HORIZON), {
|
||||
weekStartsOn: 1,
|
||||
}),
|
||||
{ hours: 14 },
|
||||
),
|
||||
minMessage: "errors.dateInPast",
|
||||
maxMessage: "errors.dateTooFarAway",
|
||||
}),
|
||||
@@ -84,7 +100,49 @@ export const addTeamEventSchema = v.object({
|
||||
items: [...teamEventDurationItems],
|
||||
initialValue: "60",
|
||||
}),
|
||||
});
|
||||
participants: radioGroup({
|
||||
label: "labels.participants",
|
||||
items: [
|
||||
{ label: "options.participants.all", value: "ALL" },
|
||||
{ label: "options.participants.selected", value: "SELECTED" },
|
||||
],
|
||||
}),
|
||||
participantUserIds: checkboxGroupDynamic({
|
||||
label: "labels.members",
|
||||
}),
|
||||
};
|
||||
|
||||
const validateSelectedParticipants = (
|
||||
data: { participants: "ALL" | "SELECTED"; participantUserIds: Array<string> },
|
||||
ctx: ValidationCtx,
|
||||
) => {
|
||||
if (
|
||||
data.participants === "SELECTED" &&
|
||||
data.participantUserIds.length === 0
|
||||
) {
|
||||
ctx.addIssue({
|
||||
path: ["participantUserIds"],
|
||||
message: "forms:errors.required",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const addTeamEventSchema = v.pipe(
|
||||
v.object({
|
||||
_action: stringConstant("ADD_EVENT"),
|
||||
...teamEventFields,
|
||||
}),
|
||||
superRefine((data, ctx) => validateSelectedParticipants(data, ctx)),
|
||||
);
|
||||
|
||||
export const editTeamEventSchema = v.pipe(
|
||||
v.object({
|
||||
_action: stringConstant("EDIT_EVENT"),
|
||||
eventId: idConstant(),
|
||||
...teamEventFields,
|
||||
}),
|
||||
superRefine((data, ctx) => validateSelectedParticipants(data, ctx)),
|
||||
);
|
||||
|
||||
const deleteTeamEventSchema = v.object({
|
||||
_action: _action("DELETE_EVENT"),
|
||||
@@ -93,5 +151,6 @@ const deleteTeamEventSchema = v.object({
|
||||
|
||||
export const teamScheduleActionSchema = v.union([
|
||||
addTeamEventSchema,
|
||||
editTeamEventSchema,
|
||||
deleteTeamEventSchema,
|
||||
]);
|
||||
|
||||
@@ -194,3 +194,9 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.eventParticipants {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import clsx from "clsx";
|
||||
import { isSameDay } from "date-fns";
|
||||
import { Flag, Plus, Trash } from "lucide-react";
|
||||
import { Flag, Pencil, Plus, Trash } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData, useMatches } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import type * as v from "valibot";
|
||||
import { ActionButton } from "~/components/ActionButton";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
@@ -15,7 +16,8 @@ import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
|
||||
import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server";
|
||||
import { getMemberRoleType } from "~/features/team/team-utils";
|
||||
import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { FormField } from "~/form/FormField";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { useHasPermission } from "~/modules/permissions/hooks";
|
||||
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
|
||||
@@ -25,6 +27,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { action } from "../actions/t.$customUrl.schedule.server";
|
||||
import {
|
||||
addTeamEventSchema,
|
||||
editTeamEventSchema,
|
||||
teamScheduleActionSchema,
|
||||
} from "../availability-schemas";
|
||||
import { scheduleWeekSearchParams } from "../availability-search-params";
|
||||
@@ -45,6 +48,8 @@ export const handle: SendouRouteHandle = {
|
||||
};
|
||||
|
||||
type WeekData = NonNullable<TeamScheduleLoaderData["weeks"]>[number];
|
||||
type TeamEventData = WeekData["teamEvents"][number];
|
||||
type TeamEventDuration = v.InferOutput<typeof editTeamEventSchema>["duration"];
|
||||
type MemberWeekRow = WeekData["members"][number];
|
||||
type TeamMember = TeamLoaderData["team"]["members"][number];
|
||||
|
||||
@@ -291,8 +296,12 @@ function WeekNotes({ week }: { week: WeekData }) {
|
||||
function TeamEvents({ week }: { week: WeekData }) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const team = useTeam();
|
||||
const members = useTeamMembers();
|
||||
const canEdit = useHasPermission(team, "EDIT");
|
||||
const [addDialogOpen, setAddDialogOpen] = React.useState(false);
|
||||
const [editedEvent, setEditedEvent] = React.useState<TeamEventData | null>(
|
||||
null,
|
||||
);
|
||||
const { formatter: dayFormatter } = useDateTimeFormat({
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
@@ -302,6 +311,19 @@ function TeamEvents({ week }: { week: WeekData }) {
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const participantNames = (event: TeamEventData) =>
|
||||
event.participants
|
||||
.map(
|
||||
(participant) =>
|
||||
members.find((member) => member.id === participant.userId)?.username,
|
||||
)
|
||||
.filter(R.isDefined)
|
||||
.join(", ");
|
||||
|
||||
// an event that already started can no longer pass the form's start time validation
|
||||
const canEditEvent = (event: TeamEventData) =>
|
||||
canEdit && databaseTimestampToDate(event.startsAt) > new Date();
|
||||
|
||||
if (week.teamEvents.length === 0 && !canEdit) return null;
|
||||
|
||||
return (
|
||||
@@ -345,6 +367,26 @@ function TeamEvents({ week }: { week: WeekData }) {
|
||||
: `${timeFormatter.format(databaseTimestampToDate(event.startsAt))} – ${timeFormatter.format(databaseTimestampToDate(event.endsAt))}`}
|
||||
</span>
|
||||
<span className={styles.eventName}>{event.name}</span>
|
||||
{event.participants.length > 0 ? (
|
||||
<span
|
||||
className={clsx(
|
||||
styles.eventParticipants,
|
||||
"text-lighter text-xs",
|
||||
)}
|
||||
>
|
||||
{participantNames(event)}
|
||||
</span>
|
||||
) : null}
|
||||
{canEditEvent(event) ? (
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
icon={<Pencil />}
|
||||
onPress={() => setEditedEvent(event)}
|
||||
aria-label={t("schedule:events.edit")}
|
||||
data-testid={`edit-team-event-${event.id}`}
|
||||
/>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<ActionButton
|
||||
schema={teamScheduleActionSchema}
|
||||
@@ -369,6 +411,12 @@ function TeamEvents({ week }: { week: WeekData }) {
|
||||
{addDialogOpen ? (
|
||||
<AddTeamEventDialog close={() => setAddDialogOpen(false)} />
|
||||
) : null}
|
||||
{editedEvent ? (
|
||||
<EditTeamEventDialog
|
||||
event={editedEvent}
|
||||
close={() => setEditedEvent(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -379,21 +427,84 @@ function AddTeamEventDialog({ close }: { close: () => void }) {
|
||||
return (
|
||||
<SendouDialog heading={t("schedule:events.addDialogTitle")} onClose={close}>
|
||||
<SendouForm schema={addTeamEventSchema} onSuccess={close}>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="name" />
|
||||
<FormField name="startsAt" />
|
||||
<FormField name="duration" />
|
||||
<FormMessage type="info">
|
||||
{t("schedule:events.membersWillSee")}
|
||||
</FormMessage>
|
||||
</>
|
||||
)}
|
||||
<TeamEventFormFields />
|
||||
</SendouForm>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditTeamEventDialog({
|
||||
event,
|
||||
close,
|
||||
}: {
|
||||
event: TeamEventData;
|
||||
close: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
|
||||
return (
|
||||
<SendouDialog
|
||||
heading={t("schedule:events.editDialogTitle")}
|
||||
onClose={close}
|
||||
>
|
||||
<SendouForm
|
||||
schema={editTeamEventSchema}
|
||||
onSuccess={close}
|
||||
defaultValues={{
|
||||
eventId: event.id,
|
||||
name: event.name,
|
||||
startsAt: databaseTimestampToDate(event.startsAt),
|
||||
duration: String(
|
||||
(event.endsAt - event.startsAt) / 60,
|
||||
) as TeamEventDuration,
|
||||
participants: event.participants.length > 0 ? "SELECTED" : "ALL",
|
||||
participantUserIds: event.participants.map((participant) =>
|
||||
String(participant.userId),
|
||||
),
|
||||
}}
|
||||
>
|
||||
<TeamEventFormFields />
|
||||
</SendouForm>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamEventFormFields() {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField name="name" />
|
||||
<FormField name="startsAt" />
|
||||
<FormField name="duration" />
|
||||
<FormField name="participants" />
|
||||
<ParticipantUserIdsFormField />
|
||||
<FormMessage type="info">
|
||||
{t("schedule:events.membersWillSee")}
|
||||
</FormMessage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantUserIdsFormField() {
|
||||
const { values } = useFormFieldContext();
|
||||
const members = useTeamMembers();
|
||||
|
||||
if (values.participants !== "SELECTED") return null;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
name="participantUserIds"
|
||||
options={members
|
||||
.filter((member) => member.role !== "CHEERLEADER")
|
||||
.map((member) => ({
|
||||
value: String(member.id),
|
||||
label: () => member.username,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useTeam() {
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
|
||||
9
changelog/2026-09-01-team-event-participants.md
Normal file
9
changelog/2026-09-01-team-event-participants.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
navItem: calendar
|
||||
type: feature
|
||||
---
|
||||
Team event improvements
|
||||
|
||||
- When adding a team event you can now choose whether the whole team takes part or only selected members
|
||||
- Team events can now be edited
|
||||
- Team events can only be scheduled within the two weeks shown on the schedule, so a new event can no longer land outside the visible range
|
||||
@@ -1,7 +1,10 @@
|
||||
import { addWeeks } from "date-fns";
|
||||
import { NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { addTeamEventSchema } from "~/features/availability/availability-schemas";
|
||||
import {
|
||||
addTeamEventSchema,
|
||||
editTeamEventSchema,
|
||||
} from "~/features/availability/availability-schemas";
|
||||
import * as Availability from "~/features/availability/core/Availability";
|
||||
import { weekDates, weekRange } from "./helpers/availability";
|
||||
import type { Factories } from "./helpers/factories";
|
||||
@@ -513,7 +516,7 @@ test.describe("Team schedule", () => {
|
||||
await isNotVisible(schedule.locators.grid);
|
||||
});
|
||||
|
||||
test("owner adds and deletes a team event, a regular member only sees it", async ({
|
||||
test("owner adds, edits and deletes a team event, a regular member only sees it", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
@@ -540,6 +543,18 @@ test.describe("Team schedule", () => {
|
||||
"VoD review vs. FTWin",
|
||||
);
|
||||
|
||||
await page.getByTestId(/edit-team-event/).click();
|
||||
const editForm = createFormHelpers(page, editTeamEventSchema);
|
||||
await editForm.fill("name", "Strategy meeting");
|
||||
await editForm.checkItems("participants", ["SELECTED"]);
|
||||
await page.getByLabel("N-ZAP", { exact: true }).click();
|
||||
await editForm.submit();
|
||||
|
||||
await expect(schedule.locators.teamEvents).toContainText(
|
||||
"Strategy meeting",
|
||||
);
|
||||
await expect(schedule.locators.teamEvents).toContainText("N-ZAP");
|
||||
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
await schedule.goto(customUrl);
|
||||
await schedule.locators.nextWeekToggle.click();
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
"labels.members": "",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "",
|
||||
"labels.description": "Beskrivelse",
|
||||
"labels.user": "",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
"labels.members": "",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "",
|
||||
"labels.description": "Beschreibung",
|
||||
"labels.user": "",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"options.duration.5h": "5 hours",
|
||||
"options.duration.6h": "6 hours",
|
||||
"errors.dateInPast": "Date can not be in the past",
|
||||
"errors.dateTooFarAway": "Date is too far in the future",
|
||||
"errors.dateTooFarAway": "Date must be within the weeks shown on the schedule",
|
||||
"errors.dateTooEarly": "Date is too early",
|
||||
"errors.dateTooLate": "Date is too late",
|
||||
"errors.dateTooFarInFuture": "Date can not be more than 2 weeks in the future",
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "End",
|
||||
"labels.member": "Member",
|
||||
"labels.members": "Members",
|
||||
"labels.participants": "Participants",
|
||||
"options.participants.all": "Everyone",
|
||||
"options.participants.selected": "Selected members",
|
||||
"labels.urls": "URLs",
|
||||
"labels.description": "Description",
|
||||
"labels.user": "User",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "Add team event",
|
||||
"events.membersWillSee": "Members will see this on their calendar.",
|
||||
"events.none": "No events this week",
|
||||
"events.edit": "Edit event",
|
||||
"events.editDialogTitle": "Edit team event",
|
||||
"events.delete": "Delete event",
|
||||
"events.deleteConfirm": "Delete the event {{name}}?",
|
||||
"friends.availabilityOf": "{{name}}'s availability",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "Fin",
|
||||
"labels.member": "Miembro",
|
||||
"labels.members": "Miembros",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "URLs",
|
||||
"labels.description": "Descripción",
|
||||
"labels.user": "Usuario",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "Fin",
|
||||
"labels.member": "Miembro",
|
||||
"labels.members": "Miembros",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "URLs",
|
||||
"labels.description": "Descripción",
|
||||
"labels.user": "Usuario",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
"labels.members": "",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "",
|
||||
"labels.description": "Description",
|
||||
"labels.user": "",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
"labels.members": "",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "",
|
||||
"labels.description": "Description",
|
||||
"labels.user": "utilisateur",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
"labels.members": "",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "",
|
||||
"labels.description": "תיאור",
|
||||
"labels.user": "",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
"labels.members": "",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "",
|
||||
"labels.description": "Descrizione",
|
||||
"labels.user": "Utente",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
"labels.members": "",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "",
|
||||
"labels.description": "説明",
|
||||
"labels.user": "",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
"labels.members": "",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "",
|
||||
"labels.description": "설명",
|
||||
"labels.user": "",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
"labels.members": "",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "",
|
||||
"labels.description": "Beschrijving",
|
||||
"labels.user": "",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
"labels.members": "",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "",
|
||||
"labels.description": "Opis",
|
||||
"labels.user": "",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
"labels.members": "",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "",
|
||||
"labels.description": "Descrição",
|
||||
"labels.user": "",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "",
|
||||
"labels.member": "",
|
||||
"labels.members": "",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "",
|
||||
"labels.description": "Описание",
|
||||
"labels.user": "Пользователь",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
"labels.end": "结束",
|
||||
"labels.member": "成员",
|
||||
"labels.members": "成员",
|
||||
"labels.participants": "",
|
||||
"options.participants.all": "",
|
||||
"options.participants.selected": "",
|
||||
"labels.urls": "URL",
|
||||
"labels.description": "简介",
|
||||
"labels.user": "用户",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"events.addDialogTitle": "",
|
||||
"events.membersWillSee": "",
|
||||
"events.none": "",
|
||||
"events.edit": "",
|
||||
"events.editDialogTitle": "",
|
||||
"events.delete": "",
|
||||
"events.deleteConfirm": "",
|
||||
"friends.availabilityOf": "",
|
||||
|
||||
21
migrations/20260901141631-team-event-participants.ts
Normal file
21
migrations/20260901141631-team-event-participants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
|
||||
/** Participants of a team event limited to selected members. No rows for an event = the whole team takes part. */
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await trx.schema
|
||||
.createTable("TeamEventMember")
|
||||
.addColumn("teamEventId", "integer", (col) =>
|
||||
col.notNull().references("TeamEvent.id").onDelete("cascade"),
|
||||
)
|
||||
.addColumn("userId", "integer", (col) =>
|
||||
col.notNull().references("User.id").onDelete("cascade"),
|
||||
)
|
||||
.addPrimaryKeyConstraint("team_event_member_pk", [
|
||||
"teamEventId",
|
||||
"userId",
|
||||
])
|
||||
.modifyEnd(sql`strict`)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user