Skip to content

Commit 824f2a8

Browse files
authored
Merge pull request #64 from aweolumidedavid/feature/wager-invitation
feat:add wager invitation system
2 parents ef5bc98 + 777fdc4 commit 824f2a8

File tree

14 files changed

+305
-10
lines changed

14 files changed

+305
-10
lines changed

backend/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ JWT_SECRET=
77
REFRESH_TOKEN_SECRET=
88
JWT_ACCESS_EXPIRTY_TIME='15m'
99
JWT_REFRESH_EXPIRTY_TIME='7d'
10+
JWT_INVITATION_EXPIRY_TIME='24h'
1011

1112
# REDIS CONFIG
1213
REDIS_HOST=
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- CreateTable
2+
CREATE TABLE "invitations" (
3+
"id" TEXT NOT NULL,
4+
"wager_id" TEXT NOT NULL,
5+
"invited_by_id" TEXT NOT NULL,
6+
"invited_username" CITEXT NOT NULL,
7+
"status" TEXT NOT NULL DEFAULT 'pending',
8+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9+
"updated_at" TIMESTAMP(3) NOT NULL,
10+
11+
CONSTRAINT "invitations_pkey" PRIMARY KEY ("id")
12+
);
13+
14+
-- AddForeignKey
15+
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_wager_id_fkey" FOREIGN KEY ("wager_id") REFERENCES "wagers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
16+
17+
-- AddForeignKey
18+
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invited_by_id_fkey" FOREIGN KEY ("invited_by_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
19+
20+
-- AddForeignKey
21+
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invited_username_fkey" FOREIGN KEY ("invited_username") REFERENCES "users"("username") ON DELETE RESTRICT ON UPDATE CASCADE;

backend/prisma/schema.prisma

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ model Wager {
3838
// Relationships
3939
category Category @relation(fields: [categoryId], references: [id])
4040
createdBy User @relation(fields: [createdById], references: [id])
41+
invitations Invitation[]
4142
4243
@@map("wagers")
4344
}
4445

45-
4646
model User {
4747
id String @id @default(uuid())
4848
email String? @unique
@@ -54,6 +54,8 @@ model User {
5454
createdAt DateTime @default(now()) @map("created_at")
5555
updatedAt DateTime @updatedAt @map("updated_at")
5656
wagers Wager[]
57+
sentInvitations Invitation[] @relation("SentInvitations")
58+
receivedInvitations Invitation[] @relation("ReceivedInvitations")
5759
5860
@@map("users")
5961
}
@@ -70,4 +72,21 @@ model Hashtag {
7072
updatedAt DateTime @default(now()) @map("updated_at")
7173
7274
@@map("hashtags")
75+
}
76+
77+
model Invitation {
78+
id String @id @default(uuid())
79+
wagerId String @map("wager_id")
80+
invitedById String @map("invited_by_id")
81+
invitedUsername String @map("invited_username") @db.Citext
82+
status String @default("pending")
83+
createdAt DateTime @default(now()) @map("created_at")
84+
updatedAt DateTime @updatedAt @map("updated_at")
85+
86+
// Relationships
87+
wager Wager @relation(fields: [wagerId], references: [id])
88+
invitedBy User @relation(fields: [invitedById], references: [id], name: "SentInvitations")
89+
invitedUser User @relation(fields: [invitedUsername], references: [username], name: "ReceivedInvitations")
90+
91+
@@map("invitations")
7392
}

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { APP_FILTER, APP_PIPE } from '@nestjs/core';
1313
import { CategoryModule } from './category/category.module';
1414
import { WagerModule } from './wager/wager.module';
1515
import { HashtagsModule } from './hashtags/hashtags.module';
16+
import { InvitationModule } from './wagerInvitations/wagerInvitations.module';
1617

1718
@Module({
1819
imports: [
@@ -21,6 +22,7 @@ import { HashtagsModule } from './hashtags/hashtags.module';
2122
CategoryModule,
2223
WagerModule,
2324
HashtagsModule,
25+
InvitationModule,
2426
ConfigModule.forRoot({
2527
isGlobal: true,
2628
cache: true,

backend/src/auth/auth.service.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
import {
2-
BadRequestException,
3-
HttpException,
4-
Inject,
5-
Injectable,
6-
} from '@nestjs/common';
1+
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
72
import { UsersService } from 'src/users/users.service';
83
import { CreateUserDto } from 'src/users/dto/create-user.dto';
94
import {
@@ -14,11 +9,8 @@ import {
149
} from 'starknet';
1510
import { JwtService } from '@nestjs/jwt';
1611
import { ConfigType } from '@nestjs/config';
17-
1812
import { AppConfig } from '../config';
1913
import { User } from '@prisma/client';
20-
import { UserTokenDto } from './dto/token.dto';
21-
import { StarknetHttpCodesEnum } from 'src/common/enums/httpCodes.enum';
2214

2315
@Injectable()
2416
export class AuthService {

backend/src/config/app.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export default registerAs('APP_CONFIG', () => ({
99
refreshTokenExpiry: process.env.JWT_REFRESH_EXPIRTY_TIME,
1010
nodeUrl: process.env.NODE_URL,
1111
chainId: process.env.CHAIN_ID,
12+
invitationTokenExpiry: process.env.JWT_INVITATION_EXPIRY_TIME ?? '24h',
1213
}));

backend/src/users/users.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,12 @@ export class UsersService {
6060

6161
return updatedUser;
6262
}
63+
64+
async findOneByUsername(username: string) {
65+
return this.prisma.user.findUnique({
66+
where: {
67+
username,
68+
},
69+
});
70+
}
6371
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { WagerInvitationService } from '../services/wagerInvitation.service';
3+
import { WagerInvitationController } from './wagerInvitation.controller';
4+
5+
describe('WagerInvitationController', () => {
6+
let controller: WagerInvitationController;
7+
8+
beforeEach(async () => {
9+
const module: TestingModule = await Test.createTestingModule({
10+
controllers: [WagerInvitationController],
11+
providers: [WagerInvitationService],
12+
}).compile();
13+
14+
controller = module.get<WagerInvitationController>(
15+
WagerInvitationController,
16+
);
17+
});
18+
19+
it('should be defined', () => {
20+
expect(controller).toBeDefined();
21+
});
22+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {
2+
Controller,
3+
Get,
4+
Post,
5+
Body,
6+
Param,
7+
UseGuards,
8+
ConflictException,
9+
Inject,
10+
Req,
11+
} from '@nestjs/common';
12+
import { JwtService } from '@nestjs/jwt';
13+
import { WagerInvitationService } from '../services/wagerInvitation.service';
14+
import { CreateWagerInvitationDto } from '../dtos/wagerInvitations.dto';
15+
import { CreateInvitationGuard } from '../guards/wagerInvitation.guard';
16+
import { ConfigType } from '@nestjs/config';
17+
import { AppConfig } from 'src/config';
18+
19+
@Controller('invitations')
20+
export class WagerInvitationController {
21+
constructor(
22+
@Inject(AppConfig.KEY)
23+
private appConfig: ConfigType<typeof AppConfig>,
24+
private invitationService: WagerInvitationService,
25+
private jwtService: JwtService,
26+
) {}
27+
28+
@Post('create')
29+
@UseGuards(CreateInvitationGuard)
30+
async createInvitation(
31+
@Body() data: CreateWagerInvitationDto,
32+
@Req() req: Request,
33+
) {
34+
const invitedByUserId = req['user'].sub;
35+
// Ensure user is not invited more than once to the same wager
36+
const existingInvitation =
37+
await this.invitationService.findExistingInvitation(
38+
data.wagerId,
39+
data.invitedUsername,
40+
);
41+
if (existingInvitation) {
42+
throw new ConflictException(
43+
'User has already been invited to this wager',
44+
);
45+
}
46+
const invitation = await this.invitationService.createInvitation(
47+
data,
48+
invitedByUserId,
49+
);
50+
51+
// Generate JWT token for invitation
52+
const invitationToken = await this.jwtService.signAsync(
53+
{ invitationId: invitation.id },
54+
{
55+
expiresIn: this.appConfig.invitationTokenExpiry,
56+
secret: this.appConfig.secret,
57+
},
58+
);
59+
60+
return { invitation, invitationToken };
61+
}
62+
63+
@Get('wager/:wagerId')
64+
async getInvitationsForWager(
65+
@Param('wagerId') wagerId: string,
66+
@Req() req: Request,
67+
) {
68+
const userId = req['user'].sub;
69+
return await this.invitationService.getInvitationsForWager(wagerId, userId);
70+
}
71+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { IsNotEmpty, IsString } from 'class-validator';
2+
3+
export class CreateWagerInvitationDto {
4+
@IsString()
5+
@IsNotEmpty()
6+
wagerId: string;
7+
8+
@IsString()
9+
@IsNotEmpty()
10+
invitedUsername: string;
11+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {
2+
BadRequestException,
3+
CanActivate,
4+
ConflictException,
5+
ExecutionContext,
6+
Injectable,
7+
NotFoundException,
8+
} from '@nestjs/common';
9+
import { Request } from 'express';
10+
import { validate } from 'class-validator';
11+
import { plainToInstance } from 'class-transformer';
12+
import { WagerService } from 'src/wager/services/wager.service';
13+
import { UsersService } from 'src/users/users.service';
14+
import { CreateWagerInvitationDto } from '../dtos/wagerInvitations.dto';
15+
16+
@Injectable()
17+
export class CreateInvitationGuard implements CanActivate {
18+
constructor(
19+
private readonly wagerService: WagerService,
20+
private readonly userService: UsersService,
21+
) {}
22+
23+
async canActivate(context: ExecutionContext) {
24+
const req: Request = context.switchToHttp().getRequest();
25+
const invitedById = req['user'].sub;
26+
27+
// Convert the raw body to an instance of CreateWagerDto
28+
const dto = plainToInstance(CreateWagerInvitationDto, req.body);
29+
30+
// Validate the DTO
31+
const errors = await validate(dto);
32+
if (errors.length > 0) {
33+
const formattedErrors = this.formatValidationErrors(errors);
34+
throw new BadRequestException(formattedErrors);
35+
}
36+
37+
// Validate wager exists
38+
const wager = await this.wagerService.findOneById(dto.wagerId);
39+
if (!wager) {
40+
throw new NotFoundException('Wager not found');
41+
}
42+
43+
// Validate invited user exists
44+
const invitedUser = await this.userService.findOneByUsername(
45+
dto.invitedUsername,
46+
);
47+
48+
if (!invitedUser) {
49+
throw new NotFoundException('Invited user not found');
50+
}
51+
52+
// Ensure user is not inviting themselves
53+
if (invitedUser.id === invitedById) {
54+
throw new ConflictException('You cannot invite yourself');
55+
}
56+
57+
// Attach the validated DTO to the request for later use
58+
req.body = dto;
59+
60+
return true;
61+
}
62+
private formatValidationErrors(errors: any[]) {
63+
const messages = errors.flatMap((error) => {
64+
return Object.values(error.constraints);
65+
});
66+
67+
return {
68+
message: messages,
69+
error: 'Bad Request',
70+
statusCode: 400,
71+
};
72+
}
73+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { WagerInvitationService } from './wagerInvitation.service';
3+
4+
describe('WagerInvitationService', () => {
5+
let service: WagerInvitationService;
6+
7+
beforeEach(async () => {
8+
const module: TestingModule = await Test.createTestingModule({
9+
providers: [WagerInvitationService],
10+
}).compile();
11+
12+
service = module.get<WagerInvitationService>(WagerInvitationService);
13+
});
14+
15+
it('should be defined', () => {
16+
expect(service).toBeDefined();
17+
});
18+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { PrismaService } from 'nestjs-prisma';
2+
import { CreateWagerInvitationDto } from '../dtos/wagerInvitations.dto';
3+
import { Injectable } from '@nestjs/common';
4+
5+
@Injectable()
6+
export class WagerInvitationService {
7+
constructor(private prisma: PrismaService) {}
8+
9+
async createInvitation(
10+
data: CreateWagerInvitationDto,
11+
invitedByUserId: string,
12+
) {
13+
const invitation = await this.prisma.invitation.create({
14+
data: {
15+
wagerId: data.wagerId,
16+
invitedById: invitedByUserId,
17+
invitedUsername: data.invitedUsername,
18+
status: 'pending',
19+
},
20+
});
21+
return invitation;
22+
}
23+
24+
async getInvitationsForWager(wagerId: string, invitedById: string) {
25+
const invitations = await this.prisma.invitation.findMany({
26+
where: { wagerId, invitedById },
27+
include: {
28+
invitedUser: true,
29+
wager: true,
30+
},
31+
});
32+
33+
return invitations;
34+
}
35+
36+
async findExistingInvitation(wagerId: string, invitedUsername: string) {
37+
const invitation = await this.prisma.invitation.findFirst({
38+
where: { wagerId, invitedUsername },
39+
});
40+
return invitation;
41+
}
42+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Global, Module } from '@nestjs/common';
2+
import { UsersService } from 'src/users/users.service';
3+
import { WagerService } from 'src/wager/services/wager.service';
4+
import { WagerInvitationController } from './controllers/wagerInvitation.controller';
5+
import { WagerInvitationService } from './services/wagerInvitation.service';
6+
import { JwtModule } from '@nestjs/jwt';
7+
8+
@Global()
9+
@Module({
10+
controllers: [WagerInvitationController],
11+
providers: [WagerInvitationService, UsersService, WagerService],
12+
imports: [JwtModule],
13+
})
14+
export class InvitationModule {}

0 commit comments

Comments
 (0)