Skip to content

Commit

Permalink
Merge pull request #64 from aweolumidedavid/feature/wager-invitation
Browse files Browse the repository at this point in the history
feat:add wager invitation system
  • Loading branch information
addegbenga authored Feb 13, 2025
2 parents ef5bc98 + 777fdc4 commit 824f2a8
Show file tree
Hide file tree
Showing 14 changed files with 305 additions and 10 deletions.
1 change: 1 addition & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ JWT_SECRET=
REFRESH_TOKEN_SECRET=
JWT_ACCESS_EXPIRTY_TIME='15m'
JWT_REFRESH_EXPIRTY_TIME='7d'
JWT_INVITATION_EXPIRY_TIME='24h'

# REDIS CONFIG
REDIS_HOST=
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "invitations" (
"id" TEXT NOT NULL,
"wager_id" TEXT NOT NULL,
"invited_by_id" TEXT NOT NULL,
"invited_username" CITEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,

CONSTRAINT "invitations_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_wager_id_fkey" FOREIGN KEY ("wager_id") REFERENCES "wagers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invited_by_id_fkey" FOREIGN KEY ("invited_by_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invited_username_fkey" FOREIGN KEY ("invited_username") REFERENCES "users"("username") ON DELETE RESTRICT ON UPDATE CASCADE;
21 changes: 20 additions & 1 deletion backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ model Wager {
// Relationships
category Category @relation(fields: [categoryId], references: [id])
createdBy User @relation(fields: [createdById], references: [id])
invitations Invitation[]
@@map("wagers")
}


model User {
id String @id @default(uuid())
email String? @unique
Expand All @@ -54,6 +54,8 @@ model User {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
wagers Wager[]
sentInvitations Invitation[] @relation("SentInvitations")
receivedInvitations Invitation[] @relation("ReceivedInvitations")
@@map("users")
}
Expand All @@ -70,4 +72,21 @@ model Hashtag {
updatedAt DateTime @default(now()) @map("updated_at")
@@map("hashtags")
}

model Invitation {
id String @id @default(uuid())
wagerId String @map("wager_id")
invitedById String @map("invited_by_id")
invitedUsername String @map("invited_username") @db.Citext
status String @default("pending")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationships
wager Wager @relation(fields: [wagerId], references: [id])
invitedBy User @relation(fields: [invitedById], references: [id], name: "SentInvitations")
invitedUser User @relation(fields: [invitedUsername], references: [username], name: "ReceivedInvitations")
@@map("invitations")
}
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { CategoryModule } from './category/category.module';
import { WagerModule } from './wager/wager.module';
import { HashtagsModule } from './hashtags/hashtags.module';
import { InvitationModule } from './wagerInvitations/wagerInvitations.module';

@Module({
imports: [
Expand All @@ -21,6 +22,7 @@ import { HashtagsModule } from './hashtags/hashtags.module';
CategoryModule,
WagerModule,
HashtagsModule,
InvitationModule,
ConfigModule.forRoot({
isGlobal: true,
cache: true,
Expand Down
10 changes: 1 addition & 9 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
BadRequestException,
HttpException,
Inject,
Injectable,
} from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { UsersService } from 'src/users/users.service';
import { CreateUserDto } from 'src/users/dto/create-user.dto';
import {
Expand All @@ -14,11 +9,8 @@ import {
} from 'starknet';
import { JwtService } from '@nestjs/jwt';
import { ConfigType } from '@nestjs/config';

import { AppConfig } from '../config';
import { User } from '@prisma/client';
import { UserTokenDto } from './dto/token.dto';
import { StarknetHttpCodesEnum } from 'src/common/enums/httpCodes.enum';

@Injectable()
export class AuthService {
Expand Down
1 change: 1 addition & 0 deletions backend/src/config/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export default registerAs('APP_CONFIG', () => ({
refreshTokenExpiry: process.env.JWT_REFRESH_EXPIRTY_TIME,
nodeUrl: process.env.NODE_URL,
chainId: process.env.CHAIN_ID,
invitationTokenExpiry: process.env.JWT_INVITATION_EXPIRY_TIME ?? '24h',
}));
8 changes: 8 additions & 0 deletions backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,12 @@ export class UsersService {

return updatedUser;
}

async findOneByUsername(username: string) {
return this.prisma.user.findUnique({
where: {
username,
},
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WagerInvitationService } from '../services/wagerInvitation.service';
import { WagerInvitationController } from './wagerInvitation.controller';

describe('WagerInvitationController', () => {
let controller: WagerInvitationController;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [WagerInvitationController],
providers: [WagerInvitationService],
}).compile();

controller = module.get<WagerInvitationController>(
WagerInvitationController,
);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
Controller,
Get,
Post,
Body,
Param,
UseGuards,
ConflictException,
Inject,
Req,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { WagerInvitationService } from '../services/wagerInvitation.service';
import { CreateWagerInvitationDto } from '../dtos/wagerInvitations.dto';
import { CreateInvitationGuard } from '../guards/wagerInvitation.guard';
import { ConfigType } from '@nestjs/config';
import { AppConfig } from 'src/config';

@Controller('invitations')
export class WagerInvitationController {
constructor(
@Inject(AppConfig.KEY)
private appConfig: ConfigType<typeof AppConfig>,
private invitationService: WagerInvitationService,
private jwtService: JwtService,
) {}

@Post('create')
@UseGuards(CreateInvitationGuard)
async createInvitation(
@Body() data: CreateWagerInvitationDto,
@Req() req: Request,
) {
const invitedByUserId = req['user'].sub;
// Ensure user is not invited more than once to the same wager
const existingInvitation =
await this.invitationService.findExistingInvitation(
data.wagerId,
data.invitedUsername,
);
if (existingInvitation) {
throw new ConflictException(
'User has already been invited to this wager',
);
}
const invitation = await this.invitationService.createInvitation(
data,
invitedByUserId,
);

// Generate JWT token for invitation
const invitationToken = await this.jwtService.signAsync(
{ invitationId: invitation.id },
{
expiresIn: this.appConfig.invitationTokenExpiry,
secret: this.appConfig.secret,
},
);

return { invitation, invitationToken };
}

@Get('wager/:wagerId')
async getInvitationsForWager(
@Param('wagerId') wagerId: string,
@Req() req: Request,
) {
const userId = req['user'].sub;
return await this.invitationService.getInvitationsForWager(wagerId, userId);
}
}
11 changes: 11 additions & 0 deletions backend/src/wagerInvitations/dtos/wagerInvitations.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';

export class CreateWagerInvitationDto {
@IsString()
@IsNotEmpty()
wagerId: string;

@IsString()
@IsNotEmpty()
invitedUsername: string;
}
73 changes: 73 additions & 0 deletions backend/src/wagerInvitations/guards/wagerInvitation.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
BadRequestException,
CanActivate,
ConflictException,
ExecutionContext,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Request } from 'express';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { WagerService } from 'src/wager/services/wager.service';
import { UsersService } from 'src/users/users.service';
import { CreateWagerInvitationDto } from '../dtos/wagerInvitations.dto';

@Injectable()
export class CreateInvitationGuard implements CanActivate {
constructor(
private readonly wagerService: WagerService,
private readonly userService: UsersService,
) {}

async canActivate(context: ExecutionContext) {
const req: Request = context.switchToHttp().getRequest();
const invitedById = req['user'].sub;

// Convert the raw body to an instance of CreateWagerDto
const dto = plainToInstance(CreateWagerInvitationDto, req.body);

// Validate the DTO
const errors = await validate(dto);
if (errors.length > 0) {
const formattedErrors = this.formatValidationErrors(errors);
throw new BadRequestException(formattedErrors);
}

// Validate wager exists
const wager = await this.wagerService.findOneById(dto.wagerId);
if (!wager) {
throw new NotFoundException('Wager not found');
}

// Validate invited user exists
const invitedUser = await this.userService.findOneByUsername(
dto.invitedUsername,
);

if (!invitedUser) {
throw new NotFoundException('Invited user not found');
}

// Ensure user is not inviting themselves
if (invitedUser.id === invitedById) {
throw new ConflictException('You cannot invite yourself');
}

// Attach the validated DTO to the request for later use
req.body = dto;

return true;
}
private formatValidationErrors(errors: any[]) {
const messages = errors.flatMap((error) => {
return Object.values(error.constraints);
});

return {
message: messages,
error: 'Bad Request',
statusCode: 400,
};
}
}
18 changes: 18 additions & 0 deletions backend/src/wagerInvitations/services/wager.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WagerInvitationService } from './wagerInvitation.service';

describe('WagerInvitationService', () => {
let service: WagerInvitationService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [WagerInvitationService],
}).compile();

service = module.get<WagerInvitationService>(WagerInvitationService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});
});
42 changes: 42 additions & 0 deletions backend/src/wagerInvitations/services/wagerInvitation.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { PrismaService } from 'nestjs-prisma';
import { CreateWagerInvitationDto } from '../dtos/wagerInvitations.dto';
import { Injectable } from '@nestjs/common';

@Injectable()
export class WagerInvitationService {
constructor(private prisma: PrismaService) {}

async createInvitation(
data: CreateWagerInvitationDto,
invitedByUserId: string,
) {
const invitation = await this.prisma.invitation.create({
data: {
wagerId: data.wagerId,
invitedById: invitedByUserId,
invitedUsername: data.invitedUsername,
status: 'pending',
},
});
return invitation;
}

async getInvitationsForWager(wagerId: string, invitedById: string) {
const invitations = await this.prisma.invitation.findMany({
where: { wagerId, invitedById },
include: {
invitedUser: true,
wager: true,
},
});

return invitations;
}

async findExistingInvitation(wagerId: string, invitedUsername: string) {
const invitation = await this.prisma.invitation.findFirst({
where: { wagerId, invitedUsername },
});
return invitation;
}
}
14 changes: 14 additions & 0 deletions backend/src/wagerInvitations/wagerInvitations.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { UsersService } from 'src/users/users.service';
import { WagerService } from 'src/wager/services/wager.service';
import { WagerInvitationController } from './controllers/wagerInvitation.controller';
import { WagerInvitationService } from './services/wagerInvitation.service';
import { JwtModule } from '@nestjs/jwt';

@Global()
@Module({
controllers: [WagerInvitationController],
providers: [WagerInvitationService, UsersService, WagerService],
imports: [JwtModule],
})
export class InvitationModule {}

0 comments on commit 824f2a8

Please sign in to comment.