supabase persistence and multiple api fixes

This commit is contained in:
oscargonzalezmoreno@gmail.com 2024-12-26 18:06:42 +01:00
parent ff50da2afd
commit 37586abe97
17 changed files with 438 additions and 505 deletions

View file

@ -2,4 +2,7 @@ GITHUB_TOKEN=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
GITHUB_REPO_OWNER=zen-browser GITHUB_REPO_OWNER=zen-browser
GITHUB_REPO_NAME=rices-store GITHUB_REPO_NAME=rices-store
SUPABASE_URL=https://xxxxxxxxxxxxx.supabase.co
SUPABASE_KEY=XXXXXXXXXXXXXXXXXXX
MODERATION_SECRET=superSecret123 MODERATION_SECRET=superSecret123

View file

@ -20,8 +20,6 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^8.1.0", "@nestjs/swagger": "^8.1.0",
"@octokit/rest": "^18.12.0", "@octokit/rest": "^18.12.0",
@ -37,10 +35,13 @@
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.0.0", "@nestjs/cli": "^10.0.0",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/schematics": "^10.0.0", "@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0", "@nestjs/testing": "^10.0.0",
"@nestjs/throttler": "^6.3.0", "@nestjs/throttler": "^6.3.0",
"@supabase/supabase-js": "^2.47.10",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",

25
sql/ddl_1.0.0.sql Normal file
View file

@ -0,0 +1,25 @@
-- DROP TABLE IF EXISTS rices;
CREATE TABLE rices (
id UUID NOT NULL, -- Unique identifier
slug VARCHAR(75) NOT NULL, -- Unique user-friendly identifier
name VARCHAR(75) NOT NULL, -- Name of the rice
token UUID NOT NULL, -- Unique authorization token
visits INTEGER DEFAULT 0 NOT NULL, -- Visit counter, initialized to 0
level INTEGER DEFAULT 0 NOT NULL, -- Level: 0 (Public), 1 (Verified)
created_at TIMESTAMP DEFAULT NOW(), -- Creation date
updated_at TIMESTAMP, -- Last update date
PRIMARY KEY (id, slug), -- Composite primary key
UNIQUE (slug), -- Ensure slug is unique
UNIQUE (name) -- Ensure name is unique
);
CREATE OR REPLACE FUNCTION increment_visits(slug_param TEXT)
RETURNS VOID AS $$
BEGIN
UPDATE rices
SET visits = visits + 1
WHERE slug = slug_param;
END;
$$ LANGUAGE plpgsql;

View file

@ -146,9 +146,7 @@ export class GitHubService implements OnModuleInit {
branch: this.defaultBranch, branch: this.defaultBranch,
}); });
this.logger.log( this.logger.log(`File ${filePath} created/updated successfully.`);
`File ${filePath} created/updated successfully.`,
);
return; return;
} catch (error: any) { } catch (error: any) {
if (error.status === 409 && attempt < retries) { if (error.status === 409 && attempt < retries) {
@ -241,7 +239,9 @@ export class GitHubService implements OnModuleInit {
} }
if (isOctokitResponseError(error) && error.status === 404) { if (isOctokitResponseError(error) && error.status === 404) {
this.logger.warn(`The file ${filePath} does not exist in the repository.`); this.logger.warn(
`The file ${filePath} does not exist in the repository.`,
);
return; return;
} }
@ -330,9 +330,7 @@ export class GitHubService implements OnModuleInit {
`Error listing files in ${directoryPath}: ${error.message} (Status: ${error.status})`, `Error listing files in ${directoryPath}: ${error.message} (Status: ${error.status})`,
); );
} else { } else {
this.logger.error( this.logger.error(`Error listing files in ${directoryPath}: ${error}`);
`Error listing files in ${directoryPath}: ${error}`,
);
} }
throw error; throw error;
} }
@ -434,9 +432,7 @@ export class GitHubService implements OnModuleInit {
*/ */
private async lockDirectory(directoryPath: string): Promise<void> { private async lockDirectory(directoryPath: string): Promise<void> {
while (this.directoryLocks.get(directoryPath)) { while (this.directoryLocks.get(directoryPath)) {
this.logger.warn( this.logger.warn(`Directory ${directoryPath} is locked. Waiting...`);
`Directory ${directoryPath} is locked. Waiting...`,
);
await this.delay(100); // Wait 100ms before retrying await this.delay(100); // Wait 100ms before retrying
} }
this.directoryLocks.set(directoryPath, true); this.directoryLocks.set(directoryPath, true);

View file

@ -1,7 +1,9 @@
import { IsOptional, IsString } from 'class-validator'; import { IsString } from 'class-validator';
export class CreateRiceDto { export class CreateRiceDto {
@IsOptional()
@IsString() @IsString()
name?: string; name!: string;
@IsString()
content!: string;
} }

View file

@ -1,7 +1,6 @@
import { IsOptional, IsString } from 'class-validator'; import { IsString } from 'class-validator';
export class UpdateRiceDto { export class UpdateRiceDto {
@IsOptional()
@IsString() @IsString()
name?: string; content!: string;
} }

View file

@ -6,7 +6,6 @@ import {
Delete, Delete,
Param, Param,
Body, Body,
UploadedFile,
UseInterceptors, UseInterceptors,
Headers, Headers,
HttpCode, HttpCode,
@ -23,8 +22,8 @@ import {
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiConsumes, ApiConsumes,
ApiBody,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
@ApiTags('rices') @ApiTags('rices')
@Controller('rices') @Controller('rices')
@ -34,45 +33,57 @@ export class RicesController {
@ApiOperation({ summary: 'Upload a new Rice' }) @ApiOperation({ summary: 'Upload a new Rice' })
@ApiResponse({ status: 201, description: 'Rice successfully created.' }) @ApiResponse({ status: 201, description: 'Rice successfully created.' })
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiBody({
description: 'Data required to create a rice',
schema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name of the rice',
example: 'My First Rice',
},
content: {
type: 'string',
description: 'The JSON content to upload',
},
},
},
})
@Post() @Post()
@UseInterceptors(FileInterceptor('file')) async createRice(@Body() createRiceDto: CreateRiceDto) {
async createRice( return this.ricesService.create(createRiceDto);
@Body() createRiceDto: CreateRiceDto,
@UploadedFile() file: Express.Multer.File,
) {
return this.ricesService.create(createRiceDto, file);
} }
@ApiOperation({ summary: 'Get information about a Rice' }) @ApiOperation({ summary: 'Get information about a Rice' })
@ApiResponse({ status: 200, description: 'Returns metadata of the Rice.' }) @ApiResponse({ status: 200, description: 'Returns metadata of the Rice.' })
@Get(':identifier') @Get(':slug')
async getRice(@Param('identifier') identifier: string) { async getRice(@Param('slug') slug: string) {
return this.ricesService.findOne(identifier); return this.ricesService.findOne(slug);
} }
@ApiOperation({ summary: 'Update an existing Rice' }) @ApiOperation({ summary: 'Update an existing Rice' })
@ApiResponse({ status: 200, description: 'Rice successfully updated.' }) @ApiResponse({ status: 200, description: 'Rice successfully updated.' })
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@Put(':identifier') @Put(':slug')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
async updateRice( async updateRice(
@Param('identifier') identifier: string, @Param('slug') slug: string,
@Headers('x-rices-token') token: string, @Headers('x-rices-token') token: string,
@Body() updateRiceDto: UpdateRiceDto, @Body() updateRiceDto: UpdateRiceDto,
@UploadedFile() file?: Express.Multer.File,
) { ) {
return this.ricesService.update(identifier, token, updateRiceDto, file); return this.ricesService.update(slug, token, updateRiceDto);
} }
@ApiOperation({ summary: 'Delete an existing Rice' }) @ApiOperation({ summary: 'Delete an existing Rice' })
@ApiResponse({ status: 204, description: 'Rice successfully deleted.' }) @ApiResponse({ status: 204, description: 'Rice successfully deleted.' })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@Delete(':identifier') @Delete(':slug')
async removeRice( async removeRice(
@Param('identifier') identifier: string, @Param('slug') slug: string,
@Headers('x-rices-token') token: string, @Headers('x-rices-token') token: string,
) { ) {
await this.ricesService.remove(identifier, token); await this.ricesService.remove(slug, token);
return; return;
} }
@ -86,9 +97,9 @@ export class RicesController {
}) })
@ApiResponse({ status: 204, description: 'Rice deleted by moderation.' }) @ApiResponse({ status: 204, description: 'Rice deleted by moderation.' })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@Delete('moderate/delete/:identifier') @Delete('moderate/delete/:slug')
async removeRiceByModerator( async removeRiceByModerator(
@Param('identifier') identifier: string, @Param('slug') slug: string,
@Headers('x-moderation-secret') moderationSecret: string, @Headers('x-moderation-secret') moderationSecret: string,
) { ) {
// Verify the secret // Verify the secret
@ -97,7 +108,7 @@ export class RicesController {
} }
// Call the service to delete without a token // Call the service to delete without a token
await this.ricesService.moderateRemove(identifier); await this.ricesService.moderateRemove(slug);
return; return;
} }
} }

View file

@ -4,10 +4,11 @@ import { Module } from '@nestjs/common';
import { RicesService } from './rices.service'; import { RicesService } from './rices.service';
import { GitHubModule } from '../github/github.module'; import { GitHubModule } from '../github/github.module';
import { RicesController } from './rices.controller'; import { RicesController } from './rices.controller';
import { SupabaseService } from '../supabase/supabase.service';
@Module({ @Module({
imports: [GitHubModule], imports: [GitHubModule],
providers: [RicesService], providers: [RicesService, SupabaseService],
controllers: [RicesController], controllers: [RicesController],
exports: [RicesService], exports: [RicesService],
}) })

View file

@ -1,296 +1,172 @@
// src/rices/rices.service.ts
import { import {
Injectable, Injectable,
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
ConflictException,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreateRiceDto } from './dto/create-rice.dto'; import { CreateRiceDto } from './dto/create-rice.dto';
import { UpdateRiceDto } from './dto/update-rice.dto'; import { UpdateRiceDto } from './dto/update-rice.dto';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { generateSlug } from './utils/slug.util'; import { generateSlug } from './utils/slug.util';
import { GitHubService } from '../github/github.service'; import { GitHubService } from '../github/github.service';
import { SupabaseService } from '../supabase/supabase.service';
/**
* Checks if the provided error has a 'status' property of type 'number'
* and a 'message' property of type 'string'.
*/
function isOctokitResponseError(
error: unknown,
): error is { status: number; message: string } {
return (
error !== null &&
typeof error === 'object' &&
'status' in error &&
typeof (error as any).status === 'number' &&
'message' in error &&
typeof (error as any).message === 'string'
);
}
@Injectable() @Injectable()
export class RicesService { export class RicesService {
constructor(private readonly gitHubService: GitHubService) {} constructor(
private readonly gitHubService: GitHubService,
private readonly supabaseService: SupabaseService,
) {}
/** async create(createRiceDto: CreateRiceDto) {
* Create a new rice // Check if a rice with the same name already exists
*/ const existingRice = await this.supabaseService.getRiceByName(
async create(createRiceDto: CreateRiceDto, file?: Express.Multer.File) { createRiceDto.name,
try { );
// 1. Generate identifier (slug + UUID or just UUID) if (existingRice) {
let identifier: string; throw new ConflictException(
if (createRiceDto.name) { `A rice with the name '${createRiceDto.name}' already exists.`,
// Generate slug from the name );
const slug = generateSlug(createRiceDto.name); }
identifier = `${slug}-${uuidv4()}`;
} else {
identifier = uuidv4();
}
// 2. Generate token and save metadata const slug = createRiceDto.name
const token = uuidv4(); ? `${generateSlug(createRiceDto.name)}-${uuidv4()}`
const metadata = { : uuidv4();
id: identifier,
token,
name: createRiceDto.name || null,
createdAt: new Date().toISOString(),
};
const metadataContent = JSON.stringify(metadata, null, 2);
const riceJsonPath = `rices/${identifier}/rice.json`;
// 3. Create or update rice.json in GitHub const token = uuidv4();
const metadata = {
id: uuidv4(),
token,
name: createRiceDto.name || null,
slug: slug,
visits: 0,
level: 0,
created_at: new Date().toISOString(),
};
await this.supabaseService.insertRice(metadata);
const metadataContent = JSON.stringify(metadata, null, 2);
const riceJsonPath = `rices/${slug}/rice.json`;
await this.gitHubService.createOrUpdateFile(
riceJsonPath,
metadataContent,
`Add rice ${slug}`,
);
if (createRiceDto.content) {
const uploadedFilePath = `rices/${slug}/data.zenrice`;
await this.gitHubService.createOrUpdateFile( await this.gitHubService.createOrUpdateFile(
riceJsonPath, uploadedFilePath,
metadataContent, createRiceDto.content,
`Add rice ${identifier}`, `Add file createRiceDto.content to rice ${slug}`,
); );
// 4. If there's a file, upload it to GitHub
if (file && file.originalname && file.buffer) {
const fileContent = file.buffer.toString('utf-8');
const uploadedFilePath = `rices/${identifier}/data.zenrice`;
await this.gitHubService.createOrUpdateFile(
uploadedFilePath,
fileContent,
`Add file ${file.originalname} to rice ${identifier}/data.zenrice`,
);
}
// 5. Return identifier and token
return {
identifier,
token,
};
} catch (error) {
console.error('Error creating the rice:', error);
throw new Error('Failed to create rice');
} }
return { slug, token };
} }
/** async findOne(slug: string) {
* Get rice information by its identifier // Check if the rice exists in the database
*/ const rice = await this.supabaseService.getRiceBySlug(slug);
async findOne(identifier: string) { if (!rice) throw new NotFoundException('Rice not found');
try {
const riceJsonPath = `rices/${identifier}/data.zenrice`;
const fileContent = await this.gitHubService.getFileContent(riceJsonPath);
if (!fileContent) { // Fetch the file from GitHub
throw new NotFoundException('Rice not found'); const filePath = `rices/${slug}/data.zenrice`;
} const fileContent = await this.gitHubService.getFileContent(filePath);
return fileContent; if (!fileContent) {
} catch (error) { throw new NotFoundException('Rice file not found in GitHub');
if (isOctokitResponseError(error) && error.status === 404) {
throw new NotFoundException('Rice not found');
}
console.error('Error getting the rice:', error);
throw new Error('Failed to get rice');
} }
return { slug, fileContent };
} }
/** async update(slug: string, token: string, updateRiceDto: UpdateRiceDto) {
* Update an existing rice const rice = await this.supabaseService.getRiceBySlug(slug);
*/ if (!rice) throw new NotFoundException('Rice not found');
async update( if (rice.token !== token) throw new UnauthorizedException('Invalid token');
identifier: string,
token: string,
updateRiceDto: UpdateRiceDto,
file?: Express.Multer.File,
) {
try {
// 1. Retrieve and validate metadata
const riceJsonPath = `rices/${identifier}/rice.json`;
const metadataContent =
await this.gitHubService.getFileContent(riceJsonPath);
if (!metadataContent) { const updatedMetadata = {
throw new NotFoundException('Rice not found'); ...rice,
} updated_at: new Date().toISOString(),
};
const metadata = JSON.parse(metadataContent); await this.supabaseService.updateRice(slug, updatedMetadata);
if (metadata.token !== token) { const metadataContent = JSON.stringify(updatedMetadata, null, 2);
throw new UnauthorizedException('Invalid token'); const riceJsonPath = `rices/${slug}/rice.json`;
} await this.gitHubService.createOrUpdateFile(
riceJsonPath,
metadataContent,
`Update rice ${slug}`,
);
// 2. Update metadata if (updateRiceDto.content) {
if (updateRiceDto.name) { const uploadedFilePath = `rices/${slug}/data.zenrice`;
metadata.name = updateRiceDto.name;
}
metadata.updatedAt = new Date().toISOString();
const updatedMetadataContent = JSON.stringify(metadata, null, 2);
// 3. Update rice.json in GitHub
await this.gitHubService.createOrUpdateFile( await this.gitHubService.createOrUpdateFile(
riceJsonPath, uploadedFilePath,
updatedMetadataContent, updateRiceDto.content,
`Update rice ${identifier}`, `Update file updateRiceDto.content in rice ${slug}`,
); );
// 4. If there's a file, update it in GitHub
if (file && file.originalname && file.buffer) {
const fileContent = file.buffer.toString('utf-8');
const uploadedFilePath = `rices/${identifier}/data.zenrice`;
await this.gitHubService.createOrUpdateFile(
uploadedFilePath,
fileContent,
`Update file ${file.originalname} in rice ${identifier}/data.zenrice`,
);
}
return {
message: `Rice ${identifier} updated`,
};
} catch (error) {
if (isOctokitResponseError(error)) {
if (error.status === 404) {
throw new NotFoundException('Rice not found');
}
if (error.status === 401 || error.status === 403) {
throw new UnauthorizedException('Invalid token');
}
}
console.error('Error updating the rice:', error);
throw new Error('Failed to update rice');
} }
return { message: `ok` };
} }
/** async remove(slug: string, token: string): Promise<void> {
* Delete an existing rice const rice = await this.supabaseService.getRiceBySlug(slug);
*/ if (!rice) throw new NotFoundException('Rice not found');
async remove(identifier: string, token: string): Promise<void> { if (rice.token !== token) throw new UnauthorizedException('Invalid token');
try {
// 1. Retrieve and validate metadata
const riceJsonPath = `rices/${identifier}/rice.json`;
const metadataContent =
await this.gitHubService.getFileContent(riceJsonPath);
if (!metadataContent) { await this.supabaseService.deleteRice(slug);
throw new NotFoundException('Rice not found');
}
const metadata = JSON.parse(metadataContent); const riceJsonPath = `rices/${slug}/rice.json`;
await this.gitHubService.deleteFile(riceJsonPath, `Remove rice ${slug}`);
if (metadata.token !== token) {
throw new UnauthorizedException('Invalid token');
}
// 2. Delete rice.json from GitHub
await this.gitHubService.deleteFile(
riceJsonPath,
`Remove rice ${identifier}`,
);
// 3. List and delete uploaded files (if any)
const uploadedFilesPath = `rices/${identifier}`;
const files =
await this.gitHubService.listFilesInDirectory(uploadedFilesPath);
for (const file of files) {
if (file !== 'rice.json') {
const filePath = `rices/${identifier}/${file}`;
await this.gitHubService.deleteFile(
filePath,
`Remove file ${file} from rice ${identifier}`,
);
}
}
} catch (error) {
if (isOctokitResponseError(error)) {
if (error.status === 404) {
throw new NotFoundException('Rice not found');
}
if (error.status === 401 || error.status === 403) {
throw new UnauthorizedException('Invalid token');
}
}
console.error('Error deleting the rice:', error);
throw new Error('Failed to remove rice');
}
} }
/** /**
* Delete a rice without checking the user's token. * Delete a rice without checking the user's token.
* Exclusive use for moderators with the secret key. * Exclusive use for moderators with the secret key.
*/ */
public async moderateRemove(identifier: string): Promise<void> { public async moderateRemove(slug: string): Promise<void> {
try { try {
// 1. Check if rice.json exists // 1. Check if rice exists in Supabase
const riceJsonPath = `rices/${identifier}/rice.json`; const rice = await this.supabaseService.getRiceBySlug(slug);
const metadataContent = if (!rice) {
await this.gitHubService.getFileContent(riceJsonPath);
if (!metadataContent) {
throw new NotFoundException('Rice not found'); throw new NotFoundException('Rice not found');
} }
// 2. Delete rice.json from GitHub // 2. Delete metadata from Supabase
await this.supabaseService.deleteRice(slug);
// 3. Delete rice.json from GitHub
const riceJsonPath = `rices/${slug}/rice.json`;
await this.gitHubService.deleteFile( await this.gitHubService.deleteFile(
riceJsonPath, riceJsonPath,
`[MODERATION] Remove rice ${identifier}`, `[MODERATION] Remove rice ${slug}`,
); );
// 3. List and delete uploaded files (if any) // 4. List and delete uploaded files from GitHub (if any)
const uploadedFilesPath = `rices/${identifier}`; const uploadedFilesPath = `rices/${slug}`;
const files = const files =
await this.gitHubService.listFilesInDirectory(uploadedFilesPath); await this.gitHubService.listFilesInDirectory(uploadedFilesPath);
for (const file of files) { for (const file of files) {
if (file !== 'rice.json') { if (file !== 'rice.json') {
const filePath = `rices/${identifier}/${file}`; const filePath = `rices/${slug}/${file}`;
await this.gitHubService.deleteFile( await this.gitHubService.deleteFile(
filePath, filePath,
`[MODERATION] Remove file ${file} from rice ${identifier}`, `[MODERATION] Remove file ${file} from rice ${slug}`,
); );
} }
} }
} catch (error) { } catch (error) {
if (isOctokitResponseError(error)) {
if (error.status === 404) {
throw new NotFoundException('Rice not found');
}
if (error.status === 401 || error.status === 403) {
throw new UnauthorizedException('Invalid token');
}
}
console.error('Error removing rice by moderation:', error); console.error('Error removing rice by moderation:', error);
if (error instanceof NotFoundException) {
throw error;
}
throw new Error('Failed to remove rice by moderation'); throw new Error('Failed to remove rice by moderation');
} }
} }
/**
* List files in a specific directory in GitHub
*/
private async listFilesInDirectory(pathInRepo: string): Promise<string[]> {
try {
return await this.gitHubService.listFilesInDirectory(pathInRepo);
} catch (error) {
if (isOctokitResponseError(error) && error.status === 404) {
return [];
}
console.error(`Error listing files in ${pathInRepo}:`, error);
throw new Error('Failed to list files in directory');
}
}
} }

View file

@ -1,6 +1,25 @@
export function generateSlug(name: string): string { export function generateSlug(name: string): string {
return name // Ensure the input is a string and trim whitespace
if (typeof name !== 'string') {
throw new Error('Input must be a string');
}
const sanitizedInput = name.trim();
// Replace accented characters with their unaccented counterparts
const normalized = sanitizedInput
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '');
// Replace any non-alphanumeric characters (excluding '-') with a hyphen
const slug = normalized
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-') // Replace invalid characters
.replace(/^-+|-+$/g, ''); .replace(/^-+|-+$/g, ''); // Trim leading and trailing hyphens
// Ensure the slug is not empty
if (!slug) {
throw new Error('Generated slug is empty');
}
return slug;
} }

View file

@ -0,0 +1,135 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
@Injectable()
export class SupabaseService {
private supabase: SupabaseClient;
private supabase_url: string;
private supabase_key: string;
private readonly logger = new Logger(SupabaseService.name);
constructor(private configService: ConfigService) {
// Initialize properties in the constructor
this.supabase_url = this.configService.get<string>('SUPABASE_URL') || '';
this.supabase_key = this.configService.get<string>('SUPABASE_KEY') || '';
this.supabase = createClient(this.supabase_url, this.supabase_key);
}
async insertRice(metadata: any) {
const { error } = await this.supabase.from('rices').insert(metadata);
if (error) {
this.logger.error(
`Failed to insert rice: ${error.message}`,
error.details,
);
throw new Error(`Failed to insert rice: ${error.message}`);
}
}
async getRiceById(id: string) {
const { data, error } = await this.supabase
.from('rices')
.select('*')
.eq('id', id)
.single();
if (error) {
this.logger.error(
`Failed to fetch rice with ID ${id}: ${error.message}`,
error.details,
);
throw new Error(`Failed to fetch rice: ${error.message}`);
}
return data;
}
async getRiceBySlug(slug: string) {
const { data, error } = await this.supabase
.from('rices')
.select('*')
.eq('slug', slug)
.single();
if (error) {
this.logger.error(
`Failed to fetch rice with slug ${slug}: ${error.message}`,
error.details,
);
throw new Error(`Failed to fetch rice: ${error.message}`);
}
return data;
}
async getRiceByName(name: string) {
const { data, error } = await this.supabase
.from('rices')
.select('*')
.eq('name', name)
.single();
if (error && error.code !== 'PGRST116') {
// Handle "no rows found" separately
this.logger.error(
`Failed to fetch rice with name ${name}: ${error.message}`,
error.details,
);
throw new Error(`Failed to fetch rice: ${error.message}`);
}
return data;
}
async updateRice(slug: string, metadata: any) {
const { error } = await this.supabase
.from('rices')
.update(metadata)
.eq('slug', slug);
if (error) {
this.logger.error(
`Failed to update rice with slug ${slug}: ${error.message}`,
error.details,
);
throw new Error(`Failed to update rice: ${error.message}`);
}
}
async deleteRice(slug: string) {
const { error } = await this.supabase
.from('rices')
.delete()
.eq('slug', slug);
if (error) {
this.logger.error(
`Failed to delete rice with slug ${slug}: ${error.message}`,
error.details,
);
throw new Error(`Failed to delete rice: ${error.message}`);
}
}
async incrementVisits(slug: string) {
const { error } = await this.supabase.rpc('increment_visits', {
slug_param: slug,
});
if (error) {
this.logger.error(
`Failed to increment visits for rice with slug ${slug}: ${error.message}`,
error.details,
);
throw new Error(`Failed to increment visits: ${error.message}`);
}
}
async updateLevel(slug: string, level: number) {
const { error } = await this.supabase
.from('rices')
.update({ level })
.eq('slug', slug);
if (error) {
this.logger.error(
`Failed to update level for rice with slug ${slug}: ${error.message}`,
error.details,
);
throw new Error(`Failed to update rice level: ${error.message}`);
}
}
}

View file

@ -5,10 +5,12 @@ import { AppModule } from './../src/app.module';
import * as path from 'path'; import * as path from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { GitHubService } from '../src/github/github.service'; import { GitHubService } from '../src/github/github.service';
import { SupabaseService } from '../src/supabase/supabase.service';
describe('Rices API E2E', () => { describe('Rices API E2E', () => {
let app: INestApplication; let app: INestApplication;
let gitHubService: GitHubService; let gitHubService: GitHubService;
let supabaseService: SupabaseService;
const moderationSecret = 'testSecret999'; const moderationSecret = 'testSecret999';
beforeAll(async () => { beforeAll(async () => {
@ -19,6 +21,7 @@ describe('Rices API E2E', () => {
}).compile(); }).compile();
gitHubService = moduleFixture.get<GitHubService>(GitHubService); gitHubService = moduleFixture.get<GitHubService>(GitHubService);
supabaseService = moduleFixture.get<SupabaseService>(SupabaseService);
app = moduleFixture.createNestApplication(); app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ transform: true })); app.useGlobalPipes(new ValidationPipe({ transform: true }));
@ -31,181 +34,138 @@ describe('Rices API E2E', () => {
}); });
beforeEach(async () => { beforeEach(async () => {
// await gitHubService.clearRepository(); // Limpiar repositorio y base de datos antes de cada test si es necesario
}); });
it('POST /rices - Create new zenrice', async () => { it('POST /rices - Create a new rice entry', async () => {
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())
.post('/rices') .post('/rices')
.field('name', 'My first zenrice') .field('name', 'Test Rice')
.attach('file', path.join(__dirname, 'files', 'example.zenrice')) .attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201); .expect(201);
expect(response.body).toHaveProperty('identifier'); const { slug, token } = response.body;
expect(response.body).toHaveProperty('token'); expect(slug).toBeDefined();
expect(token).toBeDefined();
const { identifier, token } = response.body; const riceInDatabase = await supabaseService.getRiceBySlug(slug);
expect(riceInDatabase).not.toBeNull();
expect(riceInDatabase.name).toBe('Test Rice');
const uploadedFileContent = await gitHubService.getFileContent( const fileInGitHub = await gitHubService.getFileContent(
`rices/${identifier}/data.zenrice`, `rices/${slug}/data.zenrice`,
); );
expect(uploadedFileContent).not.toBeNull(); expect(fileInGitHub).toContain('This is an example zenrice file.');
expect(uploadedFileContent).toContain('This is an example zenrice file.');
}); });
it('GET /rices/:identifier - Download zenrice', async () => { it('GET /rices/:slug - Retrieve a rice entry and increment visits', async () => {
const createResponse = await request(app.getHttpServer()) const createResponse = await request(app.getHttpServer())
.post('/rices') .post('/rices')
.field('name', 'My first zenrice') .field('name', 'Test Rice')
.attach('file', path.join(__dirname, 'files', 'example.zenrice')) .attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201); .expect(201);
const { identifier, token } = createResponse.body; const { slug } = createResponse.body;
const response = await request(app.getHttpServer()) const initialData = await supabaseService.getRiceBySlug(slug);
.get(`/rices/${identifier}`) expect(initialData.visits).toBe(0);
.expect(200);
await request(app.getHttpServer()).get(`/rices/${slug}`).expect(200);
const updatedData = await supabaseService.getRiceBySlug(slug);
expect(updatedData.visits).toBe(1);
}); });
it('PUT /rices/:identifier - Update zenrice', async () => { it('PUT /rices/:slug - Update a rice entry', async () => {
const createResponse = await request(app.getHttpServer()) const createResponse = await request(app.getHttpServer())
.post('/rices') .post('/rices')
.field('name', 'My first zenrice') .field('name', 'Original Rice')
.attach('file', path.join(__dirname, 'files', 'example.zenrice')) .attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201); .expect(201);
const { identifier, token } = createResponse.body; const { slug, token } = createResponse.body;
const updateResponse = await request(app.getHttpServer()) const updateResponse = await request(app.getHttpServer())
.put(`/rices/${identifier}`) .put(`/rices/${slug}`)
.set('x-rices-token', token) .set('x-rices-token', token)
.field('name', 'Mi rice renombrado') .field('name', 'Updated Rice')
.attach('file', path.join(__dirname, 'files', 'example_update.zenrice')) .attach('file', path.join(__dirname, 'files', 'example_update.zenrice'))
.expect(200); .expect(200);
expect(updateResponse.body).toHaveProperty( expect(updateResponse.body.message).toBe(`ok`);
'message',
`Rice ${identifier} updated`,
);
const uploadedFileContent = await gitHubService.getFileContent( const updatedData = await supabaseService.getRiceBySlug(slug);
`rices/${identifier}/data.zenrice`, expect(updatedData.name).toBe('Updated Rice');
const updatedFile = await gitHubService.getFileContent(
`rices/${slug}/data.zenrice`,
); );
expect(uploadedFileContent).not.toBeNull(); expect(updatedFile).toContain(
expect(uploadedFileContent).toContain(
'This is an example zenrice file (modified).', 'This is an example zenrice file (modified).',
); );
}); });
it('DELETE /rices/:identifier - Delete zenrice with previous token', async () => { it('DELETE /rices/:slug - Delete a rice entry', async () => {
const createResponse = await request(app.getHttpServer()) const createResponse = await request(app.getHttpServer())
.post('/rices') .post('/rices')
.field('name', 'My first zenrice') .field('name', 'Rice to Delete')
.attach('file', path.join(__dirname, 'files', 'example.zenrice')) .attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201); .expect(201);
const { identifier, token } = createResponse.body; const { slug, token } = createResponse.body;
await request(app.getHttpServer()) await request(app.getHttpServer())
.delete(`/rices/${identifier}`) .delete(`/rices/${slug}`)
.set('x-rices-token', token) .set('x-rices-token', token)
.expect(204); .expect(204);
const riceJsonContent = await gitHubService.getFileContent( const riceInDatabase = await supabaseService.getRiceBySlug(slug);
`rices/${identifier}/rice.json`, expect(riceInDatabase).toBeNull();
);
expect(riceJsonContent).toBeNull();
const uploadedFileContent = await gitHubService.getFileContent( const fileInGitHub = await gitHubService.getFileContent(
`rices/${identifier}/data.zenrice`, `rices/${slug}/data.zenrice`,
); );
expect(uploadedFileContent).toBeNull(); expect(fileInGitHub).toBeNull();
}); });
it('GET /rices/:identifier - Trying to download deleted zenrice', async () => { it('DELETE /rices/moderate/delete/:slug - Moderation delete with correct secret', async () => {
const createResponse = await request(app.getHttpServer()) const createResponse = await request(app.getHttpServer())
.post('/rices') .post('/rices')
.field('name', 'My first zenrice') .field('name', 'Moderation Test Rice')
.attach('file', path.join(__dirname, 'files', 'example.zenrice')) .attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201); .expect(201);
const { identifier, token } = createResponse.body; const { slug } = createResponse.body;
await request(app.getHttpServer()) await request(app.getHttpServer())
.delete(`/rices/${identifier}`) .delete(`/rices/moderate/delete/${slug}`)
.set('x-rices-token', token)
.expect(204);
await request(app.getHttpServer()).get(`/rices/${identifier}`).expect(404);
});
it('POST /rices - New zenrice for moderation test', async () => {
const response = await request(app.getHttpServer())
.post('/rices')
.field('name', 'Rice for moderation')
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201);
expect(response.body).toHaveProperty('identifier');
expect(response.body).toHaveProperty('token');
const { identifier, token } = response.body;
const riceJsonContent = await gitHubService.getFileContent(
`rices/${identifier}/rice.json`,
);
expect(riceJsonContent).not.toBeNull();
const riceData = JSON.parse(riceJsonContent!);
expect(riceData).toMatchObject({
id: identifier,
token,
name: 'Rice for moderation',
});
const uploadedFileContent = await gitHubService.getFileContent(
`rices/${identifier}/data.zenrice`,
);
expect(uploadedFileContent).not.toBeNull();
expect(uploadedFileContent).toContain('This is an example zenrice file.');
});
it('DELETE /rices/moderate/delete/:identifier - Delete zenrice for moderation using a correct secret', async () => {
const createResponse = await request(app.getHttpServer())
.post('/rices')
.field('name', 'Rice for moderation')
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201);
const { identifier, token } = createResponse.body;
await request(app.getHttpServer())
.delete(`/rices/moderate/delete/${identifier}`)
.set('x-moderation-secret', moderationSecret) .set('x-moderation-secret', moderationSecret)
.expect(204); .expect(204);
const riceJsonContent = await gitHubService.getFileContent( const riceInDatabase = await supabaseService.getRiceBySlug(slug);
`rices/${identifier}/rice.json`, expect(riceInDatabase).toBeNull();
);
expect(riceJsonContent).toBeNull();
const uploadedFileContent = await gitHubService.getFileContent( const fileInGitHub = await gitHubService.getFileContent(
`rices/${identifier}/data.zenrice`, `rices/${slug}/data.zenrice`,
); );
expect(uploadedFileContent).toBeNull(); expect(fileInGitHub).toBeNull();
}); });
it('DELETE /rices/moderate/delete/:identifier - Delete zenrice for moderation using an incorrect secret', async () => { it('DELETE /rices/moderate/delete/:slug - Moderation delete with incorrect secret', async () => {
const createResponse = await request(app.getHttpServer())
.post('/rices')
.field('name', 'Moderation Failure Test')
.attach('file', path.join(__dirname, 'files', 'example.zenrice'))
.expect(201);
const { slug } = createResponse.body;
await request(app.getHttpServer()) await request(app.getHttpServer())
.delete(`/rices/moderate/delete/${uuidv4()}`) .delete(`/rices/moderate/delete/${slug}`)
.set('x-moderation-secret', 'claveIncorrecta') .set('x-moderation-secret', 'wrongSecret')
.expect(401); .expect(401);
});
it('DELETE /rices/moderate/delete/:identifier - Delete non existent zenrice for moderation', async () => { const riceInDatabase = await supabaseService.getRiceBySlug(slug);
await request(app.getHttpServer()) expect(riceInDatabase).not.toBeNull();
.delete(`/rices/moderate/delete/${uuidv4()}`)
.set('x-moderation-secret', moderationSecret)
.expect(404);
}); });
}); });

View file

@ -1,18 +1,18 @@
@baseUrl = http://localhost:3000 @baseUrl = http://localhost:3000
@moderationSecret = superSecret123
@random_identifier = 123e4567-e89b-12d3-a456-426614174000 # base64 of following json: ewogICAgImtleSI6ICJ2YWx1ZSIsCiAgICAiZGVzY3JpcHRpb24iOiAiRXhhbXBsZSBjb250ZW50Igp9
# {
# "key": "value",
# "description": "Example content"
# }
POST {{baseUrl}}/rices POST {{baseUrl}}/rices
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW Content-Type: application/json
------WebKitFormBoundary7MA4YWxkTrZu0gW {
Content-Disposition: form-data; name="name" "name": "cool-zenrice-aurora2",
"content": "ewogICAgImtleSI6ICJ2YWx1ZSIsCiAgICAiZGVzY3JpcHRpb24iOiAiRXhhbXBsZSBjb250ZW50Igp9"
}
Mi primer zenrice
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="example.zenrice"
Content-Type: text/plain
This is an example zenrice file.
------WebKitFormBoundary7MA4YWxkTrZu0gW--

View file

@ -1,5 +1,4 @@
@baseUrl = http://localhost:3000 @baseUrl = http://localhost:3000
@moderationSecret = superSecret123 @previous_slug = cool-zenrice-aurora-84154784-8dc4-4153-8bd5-fd9f55f03b93
@previous_identifier = my-first-zenrice-feffbf61-b815-4357-ba0c-2cbadf94fcfe
GET {{baseUrl}}/rices/{{previous_identifier}} GET {{baseUrl}}/rices/{{previous_slug}}

View file

@ -1,19 +1,20 @@
@baseUrl = http://localhost:3000 @baseUrl = http://localhost:3000
@moderationSecret = superSecret123 @previous_slug = cool-zenrice-aurora2-1d1e74b3-8d6d-40ea-bd4f-a6f4ad893f88
@previous_identifier = my-first-zenrice-feffbf61-b815-4357-ba0c-2cbadf94fcfe @previous_token = be4545f4-d92b-416c-8b3b-50cc9a49dee9
@previous_token = 806cd360-6c14-44de-92db-f46f328dab5a
PUT {{baseUrl}}/rices/{{previous_identifier}} # base64 of following json: ewogICAgImtleSI6ICJuZXdfdmFsdWUiLAogICAgImRlc2NyaXB0aW9uIjogIkV4YW1wbGUgY29udGVudCIKfQ
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
# {
# "key": "new_value",
# "description": "Example content"
# }
PUT {{baseUrl}}/rices/{{previous_slug}}
Content-Type: application/json
x-rices-token: {{previous_token}} x-rices-token: {{previous_token}}
------WebKitFormBoundary7MA4YWxkTrZu0gW {
Content-Disposition: form-data; name="name"
Mi rice renombrado "content": "ewogICAgImtleSI6ICJuZXdfdmFsdWUiLAogICAgImRlc2NyaXB0aW9uIjogIkV4YW1wbGUgY29udGVudCIKfQ"
------WebKitFormBoundary7MA4YWxkTrZu0gW }
Content-Disposition: form-data; name="file"; filename="newfile.txt"
Content-Type: text/plain
This is an example zenrice file (modified).
------WebKitFormBoundary7MA4YWxkTrZu0gW--

View file

@ -1,7 +1,6 @@
@baseUrl = http://localhost:3000 @baseUrl = http://localhost:3000
@moderationSecret = superSecret123 @previous_slug = my-first-zenrice-b7b94d24-ecb6-4495-93de-ba85be2e3052
@previous_identifier = my-first-zenrice-b7b94d24-ecb6-4495-93de-ba85be2e3052
@previous_token = 6181664b-00e8-4eef-8e23-1f7fa0c64021 @previous_token = 6181664b-00e8-4eef-8e23-1f7fa0c64021
DELETE {{baseUrl}}/rices/{{previous_identifier}} DELETE {{baseUrl}}/rices/{{previous_slug}}
x-rices-token: {{previous_token}} x-rices-token: {{previous_token}}

View file

@ -1,94 +0,0 @@
### Variables Iniciales
@baseUrl = http://localhost:3000
@moderationSecret = superSecret123
@random_identifier = 123e4567-e89b-12d3-a456-426614174000 # Give a valid UUID
@identifier = YOUR_IDENTIFIER_HERE
@token = YOUR_TOKEN_HERE
###
### 1. Crear un Nuevo Pack
POST {{baseUrl}}/rices
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="name"
Mi primer rice
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="example.txt"
Content-Type: text/plain
Contenido de mi archivo.
------WebKitFormBoundary7MA4YWxkTrZu0gW--
### **Instrucciones Después de Ejecutar:**
1. Ejecuta esta solicitud.
2. En la respuesta, copia los valores de `identifier` y `token`.
3. Reemplaza `YOUR_IDENTIFIER_HERE` y `YOUR_TOKEN_HERE` en las variables al inicio del archivo con los valores copiados.
###
GET {{baseUrl}}/rices/{{identifier}}
###
PUT {{baseUrl}}/rices/{{identifier}}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
x-rices-token: {{token}}
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="name"
Mi rice renombrado
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="newfile.txt"
Content-Type: text/plain
Contenido de mi nuevo archivo.
------WebKitFormBoundary7MA4YWxkTrZu0gW--
###
### 4. Eliminar el Pack Usando el Token
DELETE {{baseUrl}}/rices/{{identifier}}
x-rices-token: {{token}}
###
### 5. Intentar Obtener el Pack Eliminado (Debe Retornar 404)
GET {{baseUrl}}/rices/{{identifier}}
###
### 6. Crear un Nuevo Pack para Moderación
POST {{baseUrl}}/rices
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="name"
Pack para moderación
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="example.txt"
Content-Type: text/plain
Contenido de mi archivo.
------WebKitFormBoundary7MA4YWxkTrZu0gW--
###
### 7. Eliminar el Pack por Moderación con el Secreto Correcto
DELETE {{baseUrl}}/rices/moderate/delete/{{identifier}}
x-moderation-secret: {{moderationSecret}}
###
DELETE {{baseUrl}}/rices/moderate/delete/{{random_identifier}}
x-moderation-secret: claveIncorrecta
###
DELETE {{baseUrl}}/rices/moderate/delete/{{random_identifier}}
x-moderation-secret: {{moderationSecret}}