mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-29 22:58:18 -05:00
Closes #715 - Can unite groups
This commit is contained in:
68
app/components/play/GroupCard.tsx
Normal file
68
app/components/play/GroupCard.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Form } from "remix";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button } from "~/components/Button";
|
||||
import type {
|
||||
LookingActionSchema,
|
||||
LookingLoaderDataGroup,
|
||||
} from "~/routes/play/looking";
|
||||
|
||||
export function GroupCard({
|
||||
group,
|
||||
isGroupAdmin = false,
|
||||
type,
|
||||
}: {
|
||||
group: LookingLoaderDataGroup;
|
||||
isGroupAdmin?: boolean;
|
||||
type: "LIKES_GIVEN" | "NEUTRAL" | "LIKES_RECEIVED";
|
||||
}) {
|
||||
const buttonText = () => {
|
||||
if (type === "LIKES_GIVEN") return "Undo";
|
||||
if (type === "NEUTRAL") return "Let's play!";
|
||||
|
||||
return "Group up";
|
||||
};
|
||||
const buttonValue = (): LookingActionSchema["_action"] => {
|
||||
if (type === "LIKES_GIVEN") return "UNLIKE";
|
||||
if (type === "NEUTRAL") return "LIKE";
|
||||
|
||||
return "UNITE_GROUP";
|
||||
};
|
||||
|
||||
return (
|
||||
<Form method="post">
|
||||
<div className="play-looking__card">
|
||||
<div className="play-looking__card__members">
|
||||
{group.members?.map((member) => {
|
||||
return (
|
||||
<div key={member.id} className="play-looking__member-card">
|
||||
<Avatar tiny user={member} />
|
||||
<span className="play-looking__member-name">
|
||||
{member.discordName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<input type="hidden" name="targetGroupId" value={group.id} />
|
||||
{type === "LIKES_RECEIVED" && (
|
||||
<input
|
||||
type="hidden"
|
||||
name="targetGroupSize"
|
||||
value={group.members?.length ?? -1}
|
||||
/>
|
||||
)}
|
||||
{isGroupAdmin && (
|
||||
<Button
|
||||
type="submit"
|
||||
name="_action"
|
||||
value={buttonValue()}
|
||||
tiny
|
||||
variant={type === "LIKES_GIVEN" ? "destructive" : undefined}
|
||||
>
|
||||
{buttonText()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
32
app/core/play/utils.test.ts
Normal file
32
app/core/play/utils.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { suite } from "uvu";
|
||||
import * as assert from "uvu/assert";
|
||||
import { uniteGroupInfo, UniteGroupInfoArg } from "./utils";
|
||||
|
||||
const UniteGroupInfo = suite("uniteGroupInfo()");
|
||||
|
||||
const SMALL_GROUP: UniteGroupInfoArg = { id: "small", memberCount: 1 };
|
||||
const BIG_GROUP: UniteGroupInfoArg = { id: "big", memberCount: 3 };
|
||||
|
||||
UniteGroupInfo("Removes captain if other group is smaller", () => {
|
||||
const { removeCaptainsFromOther } = uniteGroupInfo(SMALL_GROUP, BIG_GROUP);
|
||||
|
||||
assert.ok(removeCaptainsFromOther);
|
||||
});
|
||||
|
||||
UniteGroupInfo("Doesn't remove captain if groups are same size", () => {
|
||||
const { removeCaptainsFromOther } = uniteGroupInfo(SMALL_GROUP, SMALL_GROUP);
|
||||
|
||||
assert.not.ok(removeCaptainsFromOther);
|
||||
});
|
||||
|
||||
UniteGroupInfo("Bigger group survives", () => {
|
||||
let { otherGroupId, survivingGroupId } = uniteGroupInfo(
|
||||
BIG_GROUP,
|
||||
SMALL_GROUP
|
||||
);
|
||||
|
||||
assert.equal(survivingGroupId, "big");
|
||||
assert.equal(otherGroupId, "small");
|
||||
});
|
||||
|
||||
UniteGroupInfo.run();
|
||||
20
app/core/play/utils.ts
Normal file
20
app/core/play/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { UniteGroupsArgs } from "~/models/LFGGroup.server";
|
||||
|
||||
export interface UniteGroupInfoArg {
|
||||
id: string;
|
||||
memberCount: number;
|
||||
}
|
||||
export function uniteGroupInfo(
|
||||
groupA: UniteGroupInfoArg,
|
||||
groupB: UniteGroupInfoArg
|
||||
): UniteGroupsArgs {
|
||||
const survivingGroupId =
|
||||
groupA.memberCount > groupB.memberCount ? groupA.id : groupB.id;
|
||||
const otherGroupId = survivingGroupId === groupA.id ? groupB.id : groupA.id;
|
||||
|
||||
return {
|
||||
survivingGroupId,
|
||||
otherGroupId,
|
||||
removeCaptainsFromOther: groupA.memberCount !== groupB.memberCount,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { LfgGroupType } from "@prisma/client";
|
||||
import { LFG_GROUP_FULL_SIZE } from "~/constants";
|
||||
|
||||
export function isGroupAdmin({
|
||||
group,
|
||||
user,
|
||||
@@ -9,3 +12,25 @@ export function isGroupAdmin({
|
||||
(member) => member.captain && member.memberId === user.id
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that group size is suitable to be united with. E.g. if type of group
|
||||
* is QUAD and your group has 2 members then a legal group to unite with would have
|
||||
* 1 or 2 members.
|
||||
*/
|
||||
export function canUniteWithGroup({
|
||||
ownGroupType,
|
||||
ownGroupSize,
|
||||
otherGroupSize,
|
||||
}: {
|
||||
ownGroupType: LfgGroupType;
|
||||
ownGroupSize: number;
|
||||
otherGroupSize: number;
|
||||
}): boolean {
|
||||
const maxGroupSizeToConsider =
|
||||
ownGroupType === "TWIN"
|
||||
? 2 - ownGroupSize
|
||||
: LFG_GROUP_FULL_SIZE - ownGroupSize;
|
||||
|
||||
return maxGroupSizeToConsider >= otherGroupSize;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,38 @@ export function unlike({
|
||||
});
|
||||
}
|
||||
|
||||
export interface UniteGroupsArgs {
|
||||
survivingGroupId: string;
|
||||
otherGroupId: string;
|
||||
removeCaptainsFromOther: boolean;
|
||||
}
|
||||
export function uniteGroups({
|
||||
survivingGroupId,
|
||||
otherGroupId,
|
||||
removeCaptainsFromOther,
|
||||
}: UniteGroupsArgs) {
|
||||
return db.$transaction([
|
||||
db.lfgGroupMember.updateMany({
|
||||
where: { groupId: otherGroupId },
|
||||
data: {
|
||||
groupId: survivingGroupId,
|
||||
captain: removeCaptainsFromOther ? false : undefined,
|
||||
// TODO: also reset message
|
||||
},
|
||||
}),
|
||||
db.lfgGroup.delete({ where: { id: otherGroupId } }),
|
||||
db.lfgGroupLike.deleteMany({
|
||||
where: {
|
||||
OR: [{ likerId: survivingGroupId }, { targetId: survivingGroupId }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export function findById(id: string) {
|
||||
return db.lfgGroup.findUnique({ where: { id }, include: { members: true } });
|
||||
}
|
||||
|
||||
export function findActiveByMember(user: { id: string }) {
|
||||
return db.lfgGroup.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import {
|
||||
ActionFunction,
|
||||
Form,
|
||||
json,
|
||||
LinksFunction,
|
||||
LoaderFunction,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
} from "remix";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button } from "~/components/Button";
|
||||
import { GroupCard } from "~/components/play/GroupCard";
|
||||
import { LFG_GROUP_FULL_SIZE } from "~/constants";
|
||||
import { isGroupAdmin } from "~/core/play/validators";
|
||||
import { uniteGroupInfo } from "~/core/play/utils";
|
||||
import { canUniteWithGroup, isGroupAdmin } from "~/core/play/validators";
|
||||
import * as LFGGroup from "~/models/LFGGroup.server";
|
||||
import styles from "~/styles/play-looking.css";
|
||||
import { parseRequestFormData, requireUser, validate } from "~/utils";
|
||||
@@ -20,36 +20,86 @@ export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
|
||||
type ActionSchema = z.infer<typeof actionSchema>;
|
||||
const actionSchema = z.object({
|
||||
_action: z.enum(["LIKE", "UNLIKE", "UNITE_GROUP", "MATCH_UP"]),
|
||||
targetGroupId: z.string().uuid(),
|
||||
});
|
||||
export type LookingActionSchema = z.infer<typeof lookingActionSchema>;
|
||||
const lookingActionSchema = z.union([
|
||||
z.object({
|
||||
_action: z.enum(["LIKE", "UNLIKE", "MATCH_UP"]),
|
||||
targetGroupId: z.string().uuid(),
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("UNITE_GROUP"),
|
||||
targetGroupId: z.string().uuid(),
|
||||
// we also get target number size so that when you like or try to unite groups
|
||||
// what you see on your screen will be guaranteed to match what the group
|
||||
// actually is
|
||||
targetGroupSize: z.preprocess(Number, z.number().min(1).max(4).int()),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action: ActionFunction = async ({ request, context }) => {
|
||||
const data = await parseRequestFormData({
|
||||
request,
|
||||
schema: actionSchema,
|
||||
schema: lookingActionSchema,
|
||||
});
|
||||
const user = requireUser(context);
|
||||
|
||||
const group = await LFGGroup.findActiveByMember(user);
|
||||
validate(group, "No active group");
|
||||
validate(group.looking, "Group is not looking");
|
||||
validate(isGroupAdmin({ group, user }), "Not group admin");
|
||||
const ownGroup = await LFGGroup.findActiveByMember(user);
|
||||
validate(ownGroup, "No active group");
|
||||
validate(ownGroup.looking, "Group is not looking");
|
||||
validate(isGroupAdmin({ group: ownGroup, user }), "Not group admin");
|
||||
|
||||
switch (data._action) {
|
||||
case "UNITE_GROUP":
|
||||
case "UNITE_GROUP": {
|
||||
validate(
|
||||
canUniteWithGroup({
|
||||
ownGroupType: ownGroup.type,
|
||||
ownGroupSize: ownGroup.members.length,
|
||||
otherGroupSize: data.targetGroupSize,
|
||||
}),
|
||||
"Group to unite with too big"
|
||||
);
|
||||
|
||||
const groupToUniteWith = await LFGGroup.findById(data.targetGroupId);
|
||||
// let's just fail silently if they already
|
||||
// matched up with someone else or stopped looking
|
||||
if (
|
||||
!groupToUniteWith ||
|
||||
groupToUniteWith.members.length !== data.targetGroupSize ||
|
||||
!groupToUniteWith.looking
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
await LFGGroup.uniteGroups(
|
||||
uniteGroupInfo(
|
||||
{
|
||||
id: ownGroup.id,
|
||||
memberCount: ownGroup.members.length,
|
||||
},
|
||||
{
|
||||
id: groupToUniteWith.id,
|
||||
memberCount: groupToUniteWith.members.length,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case "MATCH_UP":
|
||||
case "UNLIKE": {
|
||||
await LFGGroup.unlike({
|
||||
likerId: group.id,
|
||||
likerId: ownGroup.id,
|
||||
targetId: data.targetGroupId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "LIKE": {
|
||||
await LFGGroup.like({ likerId: group.id, targetId: data.targetGroupId });
|
||||
// TODO: maybe also do the members.length check here?
|
||||
// upside: handles edge case - downside: one extra db call per like
|
||||
await LFGGroup.like({
|
||||
likerId: ownGroup.id,
|
||||
targetId: data.targetGroupId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -59,7 +109,7 @@ export const action: ActionFunction = async ({ request, context }) => {
|
||||
// TODO: notify watchers
|
||||
};
|
||||
|
||||
type LookingLoaderDataGroup = {
|
||||
export type LookingLoaderDataGroup = {
|
||||
id: string;
|
||||
members?: {
|
||||
id: string;
|
||||
@@ -74,6 +124,7 @@ interface LookingLoaderData {
|
||||
likedGroups: LookingLoaderDataGroup[];
|
||||
neutralGroups: LookingLoaderDataGroup[];
|
||||
likerGroups: LookingLoaderDataGroup[];
|
||||
ownGroup: LookingLoaderDataGroup;
|
||||
}
|
||||
|
||||
export const loader: LoaderFunction = async ({ context }) => {
|
||||
@@ -87,13 +138,6 @@ export const loader: LoaderFunction = async ({ context }) => {
|
||||
ownGroup.ranked
|
||||
);
|
||||
|
||||
// For example if we have a group of 3 and group type is QUAD we only want to consider groups of size 1
|
||||
// .. if we have a group size of 2 then we can consider groups of size 1 or 2 and so on
|
||||
const maxGroupSizeToConsider = (() => {
|
||||
if (ownGroup.type === "TWIN") return 1;
|
||||
|
||||
return LFG_GROUP_FULL_SIZE - ownGroup.members.length;
|
||||
})();
|
||||
const lookingForMatch = ownGroup.members.length === LFG_GROUP_FULL_SIZE;
|
||||
const likesGiven = ownGroup.likedGroups.reduce(
|
||||
(acc, lg) => acc.add(lg.targetId),
|
||||
@@ -104,9 +148,23 @@ export const loader: LoaderFunction = async ({ context }) => {
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
return json<LookingLoaderData>(
|
||||
groups
|
||||
.filter((group) => group.members.length <= maxGroupSizeToConsider)
|
||||
const ownGroupWithMembers = groups.find((g) => g.id === ownGroup.id);
|
||||
invariant(ownGroupWithMembers, "ownGroupWithMembers is undefined");
|
||||
const ownGroupForResponse: LookingLoaderDataGroup = {
|
||||
id: ownGroup.id,
|
||||
members: ownGroupWithMembers.members.map((m) => m.user),
|
||||
};
|
||||
|
||||
return json<LookingLoaderData>({
|
||||
ownGroup: ownGroupForResponse,
|
||||
...groups
|
||||
.filter((group) =>
|
||||
canUniteWithGroup({
|
||||
ownGroupType: ownGroup.type,
|
||||
ownGroupSize: ownGroup.members.length,
|
||||
otherGroupSize: group.members.length,
|
||||
})
|
||||
)
|
||||
.filter((group) => group.id !== ownGroup.id)
|
||||
.map((group) => ({
|
||||
id: group.id,
|
||||
@@ -115,10 +173,10 @@ export const loader: LoaderFunction = async ({ context }) => {
|
||||
members:
|
||||
group.ranked && lookingForMatch
|
||||
? undefined
|
||||
: group.members.map((member) => member.user),
|
||||
: group.members.map((m) => m.user),
|
||||
}))
|
||||
.reduce(
|
||||
(acc: LookingLoaderData, group) => {
|
||||
(acc: Omit<LookingLoaderData, "ownGroup">, group) => {
|
||||
// likesReceived first so that if both received like and
|
||||
// given like then handle this edge case by just displaying the
|
||||
// group as waiting like back
|
||||
@@ -132,8 +190,8 @@ export const loader: LoaderFunction = async ({ context }) => {
|
||||
return acc;
|
||||
},
|
||||
{ likedGroups: [], neutralGroups: [], likerGroups: [] }
|
||||
)
|
||||
);
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export default function LookingPage() {
|
||||
@@ -141,6 +199,8 @@ export default function LookingPage() {
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<GroupCard group={data.ownGroup} type="LIKES_GIVEN" />
|
||||
<hr className="my-4" />
|
||||
<div className="play-looking__columns">
|
||||
<div>
|
||||
<h2 className="play-looking__column-header">You want to play with</h2>
|
||||
@@ -191,57 +251,3 @@ export default function LookingPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupCard({
|
||||
group,
|
||||
isGroupAdmin = false,
|
||||
type,
|
||||
}: {
|
||||
group: LookingLoaderDataGroup;
|
||||
isGroupAdmin?: boolean;
|
||||
type: "LIKES_GIVEN" | "NEUTRAL" | "LIKES_RECEIVED";
|
||||
}) {
|
||||
const buttonText = () => {
|
||||
if (type === "LIKES_GIVEN") return "Undo";
|
||||
if (type === "NEUTRAL") return "Let's play!";
|
||||
|
||||
return "Group up";
|
||||
};
|
||||
const buttonValue = (): ActionSchema["_action"] => {
|
||||
if (type === "LIKES_GIVEN") return "UNLIKE";
|
||||
if (type === "NEUTRAL") return "LIKE";
|
||||
|
||||
return "MATCH_UP";
|
||||
};
|
||||
|
||||
return (
|
||||
<Form method="post">
|
||||
<div className="play-looking__card">
|
||||
<div className="play-looking__card__members">
|
||||
{group.members?.map((member) => {
|
||||
return (
|
||||
<div key={member.id} className="play-looking__member-card">
|
||||
<Avatar tiny user={member} />
|
||||
<span className="play-looking__member-name">
|
||||
{member.discordName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<input type="hidden" name="targetGroupId" value={group.id} />
|
||||
{isGroupAdmin && (
|
||||
<Button
|
||||
type="submit"
|
||||
name="_action"
|
||||
value={buttonValue()}
|
||||
tiny
|
||||
variant={type === "LIKES_GIVEN" ? "destructive" : undefined}
|
||||
>
|
||||
{buttonText()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,6 +280,10 @@ select::selection {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: var(--theme-transparent);
|
||||
}
|
||||
|
||||
/* Does not respect window getting resized */
|
||||
.modal {
|
||||
position: fixed;
|
||||
@@ -473,8 +477,11 @@ select::selection {
|
||||
}
|
||||
|
||||
.my-1-5 {
|
||||
margin-block-end: var(--s-1-5);
|
||||
margin-block-start: var(--s-1-5);
|
||||
margin-block: var(--s-1-5);
|
||||
}
|
||||
|
||||
.my-4 {
|
||||
margin-block: var(--s-4);
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
background-color: var(--bg-lighter);
|
||||
border-radius: var(--rounded);
|
||||
gap: var(--s-4);
|
||||
max-inline-size: 24rem;
|
||||
padding-block: var(--s-3);
|
||||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
@@ -81,9 +81,10 @@ export async function parseRequestFormData<T extends z.ZodTypeAny>({
|
||||
schema: T;
|
||||
}): Promise<z.infer<T>> {
|
||||
try {
|
||||
const formDataObj = Object.fromEntries(await request.formData());
|
||||
// False alarm
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return schema.parse(Object.fromEntries(await request.formData()));
|
||||
return schema.parse(formDataObj);
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) {
|
||||
console.error(e);
|
||||
|
||||
@@ -230,6 +230,15 @@ CREATE UNIQUE INDEX "TournamentMatchParticipant_teamId_matchId_key" ON "Tourname
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TournamentMatchGameResult_matchId_roundStageId_key" ON "TournamentMatchGameResult"("matchId", "roundStageId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LfgGroup_active_idx" ON "LfgGroup"("active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LfgGroup_looking_idx" ON "LfgGroup"("looking");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LfgGroup_type_idx" ON "LfgGroup"("type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LfgGroupLike_likerId_targetId_key" ON "LfgGroupLike"("likerId", "targetId");
|
||||
|
||||
@@ -306,10 +315,10 @@ ALTER TABLE "TournamentMatchGameResult" ADD CONSTRAINT "TournamentMatchGameResul
|
||||
ALTER TABLE "LfgGroup" ADD CONSTRAINT "LfgGroup_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "LfgGroupMatch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LfgGroupLike" ADD CONSTRAINT "LfgGroupLike_likerId_fkey" FOREIGN KEY ("likerId") REFERENCES "LfgGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "LfgGroupLike" ADD CONSTRAINT "LfgGroupLike_likerId_fkey" FOREIGN KEY ("likerId") REFERENCES "LfgGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LfgGroupLike" ADD CONSTRAINT "LfgGroupLike_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "LfgGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "LfgGroupLike" ADD CONSTRAINT "LfgGroupLike_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "LfgGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LfgGroupMember" ADD CONSTRAINT "LfgGroupMember_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "LfgGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -232,8 +232,8 @@ model LfgGroup {
|
||||
model LfgGroupLike {
|
||||
likerId String
|
||||
targetId String
|
||||
liker LfgGroup @relation("liker", fields: [likerId], references: [id])
|
||||
target LfgGroup @relation("target", fields: [targetId], references: [id])
|
||||
liker LfgGroup @relation("liker", fields: [likerId], references: [id], onDelete: Cascade)
|
||||
target LfgGroup @relation("target", fields: [targetId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([likerId, targetId])
|
||||
|
||||
Reference in New Issue
Block a user