Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updates #35

Merged
merged 5 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions controllers/quest.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,8 @@ export default {
getRandomQuest: async (request: Request, response: Response) => {
try {
const { userId } = (request as AuthenticatedRequest).tokenData;
const filters = request.query;
if (filters) {
//todo implement filters
// filters.split(",")
}

const randomQuest = await questService.getRandomQuestByUserId(userId);
const randomQuest = await questService.getRandomQuest(userId);

return response.send(randomQuest);
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion controllers/suggestion.controller .ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default {
return response.status(404).send('Suggestion not found');
}

if (role !== "admin" || suggestion.user_id !== userId) {
if (role !== "admin" && suggestion.user_id === userId) {
return response.status(403).send('You are not allowed to convert this suggestion into a quest');
}

Expand Down
12 changes: 12 additions & 0 deletions controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,5 +114,17 @@ export default {
console.error(error);
response.status(500).send('An error occurred while updating settings');
}
},

resetAllQuests: async (request: Request, response: Response) => {
try {
const { userId } = (request as AuthenticatedRequest).tokenData;
await userService.resetAllQuests(Number(userId));

response.send('All quests reset');
} catch (error) {
console.error(error);
response.status(500).send('An error occurred while resetting all quests');
}
}
}
48 changes: 33 additions & 15 deletions database/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ CREATE TABLE users (
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
weight INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by INTEGER REFERENCES users(id) DEFAULT NULL,
Expand All @@ -48,6 +49,15 @@ CREATE TABLE suggestions (
deleted_by INTEGER REFERENCES users(id) DEFAULT NULL
);

CREATE TABLE dlcs (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by INTEGER REFERENCES users(id) DEFAULT NULL,
deleted_by INTEGER REFERENCES users(id) DEFAULT NULL
);

-- Quests table
CREATE TABLE quests (
id SERIAL PRIMARY KEY,
Expand All @@ -56,6 +66,7 @@ CREATE TABLE quests (
title VARCHAR(255) NOT NULL,
objectives TEXT[] NOT NULL,
image_url VARCHAR(255),
required_dlc INTEGER REFERENCES dlcs(id) DEFAULT NULL,
suggested_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
Expand All @@ -71,25 +82,26 @@ INSERT INTO roles (name) VALUES
('guest');

-- Insert categories into categories table
INSERT INTO categories (name) VALUES
('PVP'),
('PVE'),
('Monuments'),
('Exploration'),
('Crafting'),
('Gambling'),
('Roleplay'),
('Automation'),
('Trolling'),
('Survival'),
('Raiding'),
('Building');
INSERT INTO categories (name, weight) VALUES
('PVP', 3),
('PVE', 4),
('Monuments', 5),
('Exploration', 5),
('Crafting', 5),
('Gambling', 1),
('Roleplay', 5),
('Automation', 2),
('Trolling', 3),
('Survival', 5),
('Raiding', 1),
('Building', 5),
('Marketplace Items', 1);

-- Insert 10 rows into users table
INSERT INTO users (username, role_id, completed_quests, metadata, password, approved_suggestions, last_login) VALUES
('zcog', 3, ARRAY[1, 3, 6, 8], '{"sound": false}', '$2b$10$ita5UtzrE2JBh.275g5i8ebBnnM99D9wZhRmcqfZYfgTjbt.baNyG', 0, NOW()),
('zcog', 3, ARRAY[1, 3, 6, 8], '{ "sound": false, "categoryFilters": [ 8, 7, 6 ], "sunburnDLCQuests": false, "disableAnimations": false, "instrumentDLCQuests": false, "voicePropsDLCQuests": false}', '$2b$10$ita5UtzrE2JBh.275g5i8ebBnnM99D9wZhRmcqfZYfgTjbt.baNyG', 0, NOW()),
('notacoconut', 3, ARRAY[7, 9], '{"sound": true}', '$2b$10$ita5UtzrE2JBh.275g5i8ebBnnM99D9wZhRmcqfZYfgTjbt.baNyG', 0, NOW()),
('demouser1', 2, ARRAY[2, 4, 7], '{"sound": true}', '$2b$10$ita5UtzrE2JBh.275g5i8ebBnnM99D9wZhRmcqfZYfgTjbt.baNyG', 2, NOW()),
('demouser1', 2, ARRAY[2, 4, 7], '{ "sound": false, "categoryFilters": [ 1, 2, 3 ], "sunburnDLCQuests": false, "disableAnimations": false, "instrumentDLCQuests": true, "voicePropsDLCQuests": false}', '$2b$10$ita5UtzrE2JBh.275g5i8ebBnnM99D9wZhRmcqfZYfgTjbt.baNyG', 2, NOW()),
('demouser2', 2, ARRAY[1, 3, 4], '{"sound": true}', '$2b$10$ita5UtzrE2JBh.275g5i8ebBnnM99D9wZhRmcqfZYfgTjbt.baNyG', 1, NOW()),
('demouser3', 1, ARRAY[5, 6], '{"sound": false}', '$2b$10$ita5UtzrE2JBh.275g5i8ebBnnM99D9wZhRmcqfZYfgTjbt.baNyG', 1, NOW()),
('demouser4', 1, ARRAY[1, 2, 8], '{"sound": true}', '$2b$10$ita5UtzrE2JBh.275g5i8ebBnnM99D9wZhRmcqfZYfgTjbt.baNyG', 1, NOW());
Expand All @@ -102,6 +114,12 @@ INSERT INTO suggestions (user_id, title, description) VALUES
(6, 'Finish the Easter Egg', 'Complete the Easter egg quest in the game'),
(3, 'Get the diamond camo for the intervention', 'Complete all the tasks for the intervention and unlock the diamond camo');

INSERT INTO dlcs (name) VALUES
('Console Edition'),
('Sunburn Pack'),
('Voice Props Pack'),
('Instruments Pack');

-- Insert 10 rows into quests table
INSERT INTO quests (category_id, description, title, objectives) VALUES
(1, 'Next locked crate that drops, contest it for yourself', 'demo Try to Claim a Locked Crate', ARRAY['kill_all_scientists','escape_with_the_loot']),
Expand Down
1 change: 1 addition & 0 deletions models/category.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
type Category = {
id: number;
name: categoryName;
weight: number;
created_at: Date;
updated_at: Date;
updated_by: number | null;
Expand Down
1 change: 0 additions & 1 deletion routes/quest.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ questRouter.get(
questController.getAllQuests
);
questRouter.get('/random-quest',
validateQuery(questSchema.filterSchema),
questController.getRandomQuest
)
questRouter.get(
Expand Down
4 changes: 4 additions & 0 deletions routes/user.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ userRouter.put(
validateBody(userSchema.settingsSchema),
userController.updateSettings
);
userRouter.delete(
'/completed-quests',
userController.resetAllQuests
);

// admin routes
userRouter.get(
Expand Down
35 changes: 35 additions & 0 deletions services/category.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,39 @@ export default {

return await executeQuery(query);
},

async getAvailableCategories(userId: number, categoryFilters: number[] | null): Promise<Category[]> {
const query = `
SELECT DISTINCT c.id, c.name, c.weight
FROM categories c
JOIN quests q ON q.category_id = c.id
WHERE
($1::INTEGER[] IS NULL OR c.id = ANY($1))
AND c.deleted_by IS NULL
AND q.id NOT IN (
SELECT UNNEST(completed_quests)
FROM users
WHERE id = $2
)
AND q.soft_deleted IS NOT TRUE
`;
const values = [categoryFilters, userId];

const categories = await executeQuery(query, values);
return categories || [];
},

selectRandomCategory(categories: Category[]): Category {
const totalWeight = categories.reduce((acc, cat) => acc + cat.weight, 0);
const randomRoll = Math.random() * totalWeight;
let currentWeight = 0;

for (const category of categories) {
currentWeight += category.weight;
if (randomRoll <= currentWeight) {
return category;
}
}
return categories[0]; // Fallback in case of an edge case.
},
}
73 changes: 37 additions & 36 deletions services/quest.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { executeQuery } from "../database/connection";
import { categoryName } from "../models/category";
import Quest from "../models/quest";
import { toSnakeCase } from "../utils/toSnakeCase";
import categoryService from "./category.service";
import userService from "./user.service";

type UpdateQuestParams = {
title?: string;
Expand Down Expand Up @@ -89,43 +91,42 @@ export default {
return await executeQuery(query, values, true);
},

getRandomQuestByUserId: async (userId: number): Promise<{
id: number;
title: string;
description: string;
objectives: string[];
image_url: string;
category: categoryName;
}> => {
const query = `
SELECT
quests.id,
quests.title,
quests.description,
quests.objectives,
quests.image_url,
categories.name AS category,
su.username
FROM
quests
JOIN
categories ON quests.category_id = categories.id
LEFT JOIN
users su on su.id = quests.suggested_by
WHERE
quests.id NOT IN (
SELECT UNNEST(completed_quests)
FROM users
WHERE id = $1
)
AND category_id IS NOT NULL
AND soft_deleted IS NOT TRUE
ORDER BY RANDOM()
LIMIT 1;
`;
const values = [userId];
async getRandomQuest(userId: number): Promise<Quest | null> {
// 1. Fetch user preferences
const { categoryFilters, completedQuests } = await userService.getUserPreferences(userId);

return await executeQuery(query, values, true);
// 2. Fetch available categories based on user filters
const availableCategories = await categoryService.getAvailableCategories(userId, categoryFilters);
if (!availableCategories.length) {
return null;
}

// 3. Select a random category
const selectedCategory = categoryService.selectRandomCategory(availableCategories);

// 4. Fetch a random quest from the selected category that the user has not completed
const questQuery = `
SELECT
quests.id,
quests.title,
quests.description,
quests.objectives,
quests.image_url,
categories.name AS category
FROM
quests
JOIN
categories ON quests.category_id = categories.id
WHERE
quests.id NOT IN (SELECT UNNEST($1::INTEGER[]))
AND category_id = $2
AND soft_deleted IS NOT TRUE
ORDER BY RANDOM()
LIMIT 1;
`;
const questValues = [completedQuests, selectedCategory.id];

return await executeQuery(questQuery, questValues, true);
},

getQuestByTitle: async (title: string): Promise<Quest> => {
Expand Down
4 changes: 3 additions & 1 deletion services/suggestion.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ export default {
u.approved_suggestions AS suggestions
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE r.name != 'guest'
WHERE
r.name != 'guest'
AND u.approved_suggestions > 0
ORDER BY u.approved_suggestions DESC,
u.id ASC
LIMIT 20;
Expand Down
25 changes: 24 additions & 1 deletion services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,28 @@ export default {
const values = [userId];

return await executeQuery(query, values);
}
},

getUserPreferences: async (userId: number): Promise<{ categoryFilters: number[] | null, completedQuests: number[] }> => {
const query = `
SELECT metadata->>'categoryFilters' AS categoryFilters, completed_quests
FROM users
WHERE id = $1
`;
const result = await executeQuery(query, [userId], true);

return {
categoryFilters: result.categoryfilters ? JSON.parse(result.categoryfilters) : null,
completedQuests: result.completed_quests,
};
},

resetAllQuests: async (userId: number): Promise<boolean> => {
const query = `UPDATE users
SET completed_quests = '{}'
WHERE id = $1`;
const values = [userId];

return await executeQuery(query, values);
},
};
2 changes: 1 addition & 1 deletion validationSchemas/questSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default {
}),

filterSchema: Joi.object({
filters: Joi.string()
categories: Joi.string()
}),

create: Joi.object({
Expand Down