Gameplay\RateAnswerFacade.php
<?php
declare(strict_types=1);
namespace App\Gameplay\Facade;
use App\EntityManager;
use App\Gameplay\Facade\Exception\GameplayFacadeException;
use App\Gameplay\Http\Request\Dto\RateAnswerDto;
use App\Student\Database\Entity\Student;
use App\Student\Database\Enum\Gender;
use App\StudentGroup\Database\Entity\StudentGroup;
use App\Task\Database\Entity\Task;
use App\Task\Database\Enum\Liked;
use App\Task\Database\Repository\AnswerRepository;
/**
* Records the group's feedback on a task (Bobřík enum: 1=Yes, -1=No, 0=Neutral).
* Re-rating overwrites the previous value.
*/
final readonly class RateAnswerFacade
{
public function __construct(
private EntityManager $em,
private AnswerRepository $answerRepository,
) {}
/**
* @throws GameplayFacadeException when the answer doesn't belong to the
* caller's group (prevents drive-by
* rating of other groups' rows).
*/
public function execute(RateAnswerDto $dto, StudentGroup $group, Student $caller): void
{
$answer = $this->answerRepository->findById($dto->answerId);
if ($answer === null) {
throw new GameplayFacadeException(translationKey: 'gameplay.error.answer_not_found');
}
if ($answer->getStudentGroup()->getId() !== $group->getId()) {
throw new GameplayFacadeException(translationKey: 'gameplay.error.answer_not_owned');
}
// Author was the only one who could have clicked submit, so they are
// also the only one who can rate — keeps the audit trail clean.
if ($answer->getAuthorStudent()->getId() !== $caller->getId()) {
throw new GameplayFacadeException(translationKey: 'gameplay.error.answer_not_owned');
}
$answer->setLiked(Liked::from($dto->liked));
$this->em->flush();
$this->recalculatePopularity($answer->getTask());
}
/**
* A rating changes the task's popularity, so recompute the two stored
* buckets from scratch. Must run *after* the rating flush above — the count
* queries read the DB, so the new `liked` value has to be persisted first;
* a second flush then writes the refreshed totals.
*/
private function recalculatePopularity(Task $task): void
{
$taskId = $task->getId();
$maleLikes = $this->answerRepository->countLikesByTaskAndGender($taskId, Gender::Male);
$femaleLikes = $this->answerRepository->countLikesByTaskAndGender($taskId, Gender::Female);
$otherLikes = $this->answerRepository->countLikesByTaskAndGender($taskId, Gender::Other);
$task->setPopularityMale($maleLikes);
$task->setPopularityFemale($femaleLikes);
$task->setPopularityOther($otherLikes);
$this->em->flush();
}
}
profile/change_password.go
package command
import (
"context"
"errors"
"time"
"gokick/app/domain/shared"
"gokick/app/domain/shared/msgkey"
"gokick/app/domain/user"
)
type ChangePasswordCommand struct {
OldPassword string
NewPassword string
}
func (ChangePasswordCommand) RequiredPermission() string { return "profile:update" }
type ChangePasswordHandler struct {
users user.Repository
password shared.PasswordHasher
}
func NewChangePasswordHandler(
users user.Repository,
password shared.PasswordHasher,
) *ChangePasswordHandler {
return &ChangePasswordHandler{
users: users,
password: password,
}
}
func (h *ChangePasswordHandler) Handle(ctx context.Context, cmd ChangePasswordCommand) error {
claims, err := shared.RequireClaims(ctx)
if err != nil {
return err
}
u, err := h.users.FindByID(ctx, claims.UserID)
if err != nil {
return err
}
if u == nil {
return &shared.ValidationError{Field: "general", Key: msgkey.UserNotFound}
}
if err := h.password.Verify(cmd.OldPassword, u.PasswordHash); err != nil {
return &shared.AuthError{Key: msgkey.AuthCurrentPasswordIncorrect}
}
newHash, err := user.HashNewPassword(cmd.NewPassword, h.password)
if err != nil {
var ve *shared.ValidationError
if errors.As(err, &ve) {
return &shared.ValidationError{Field: "new_password", Key: ve.Key, Params: ve.Params}
}
return err
}
if err := h.users.UpdatePassword(ctx, u.ID, newHash, time.Now()); err != nil {
return err
}
shared.AuditCollectorFromContext(ctx).Record(shared.AuditEvent{
Action: "user.password_changed",
TargetType: "user",
TargetID: u.ID,
})
return nil
}
<script setup lang="ts">
import type { AdminUser } from '@/app/Admin/types/AdminUser';
import { isAdminUser } from '@/app/Admin/types/AdminUser';
import type { UserFormData } from '@/app/Admin/types/UserFormData';
import type { UserFormErrors } from '@/app/Admin/types/UserFormErrors';
import { ref, watch } from 'vue';
import { authFetch, useAuth } from '@/app-ui/Auth';
import { getLocale, localizePath, useI18n } from '@/app-ui/I18n';
import { useToast } from '@/app-ui/Toast/useToast';
import Modal from '@/app-ui/Modals/Modal.vue';
import Spinner from '@/app-ui/Loading/Spinner.vue';
import UserForm from '@/app/Admin/Components/UserForm.vue';
const { userId } = defineProps<{
userId: string | null;
}>();
const emit = defineEmits<{
saved: [nickname: string];
close: [];
}>();
const { t } = useI18n();
const { error } = useToast();
const { user: currentUser } = useAuth();
const initial = ref<UserFormData | null>(null);
const errors = ref<UserFormErrors>({});
const isLoading = ref<boolean>(false);
const isFetching = ref<boolean>(false);
const clearFieldError = (field: keyof UserFormErrors): void => {
delete errors.value[field];
};
const close = (): void => {
errors.value = {};
emit('close');
};
const loadUser = async (id: string): Promise<void> => {
// Blank first — the previous subject must not stay on screen while the new one loads.
initial.value = null;
errors.value = {};
isFetching.value = true;
const result = await authFetch<AdminUser>('GET', `/api/v1/admin/users/${id}`, {
validate: isAdminUser,
});
isFetching.value = false;
if (result.success === false) {
error(t('users.load_one_failed'));
close();
return;
}
initial.value = {
nickname: result.data.nickname,
password: '',
email: result.data.email,
role: result.data.role,
};
};
watch(() => userId, (id: string | null): void => {
if (id === null) {
return;
}
void loadUser(id);
});
const handleSubmit = async (data: UserFormData): Promise<void> => {
const id = userId;
if (id === null) {
return;
}
isLoading.value = true;
errors.value = {};
const result = await authFetch<null, UserFormErrors, UserFormData>(
'PUT',
`/api/v1/admin/users/${id}`,
{ body: data },
);
isLoading.value = false;
if (result.success === false) {
errors.value = result.data;
return;
}
const isSelf = currentUser.value !== null && currentUser.value.id === id;
const roleChanged = initial.value !== null && initial.value.role !== data.role;
if (isSelf === true && roleChanged === true) {
// Admin demoted themselves: full reload so bootstrap mints a token with
// the new permissions. A router navigation would keep the stale JWT.
window.location.assign(localizePath('/dashboard', getLocale()));
return;
}
emit('saved', data.nickname);
close();
};
</script>
<template>
<Modal
:show="userId !== null"
:title="t('users.edit_title')"
@close="close"
>
<div
v-if="isFetching === true"
class="flex items-center justify-center py-12"
>
<Spinner />
</div>
<UserForm
v-else-if="initial !== null"
mode="edit"
:submit-label="t('common.save')"
:initial="initial"
:is-loading="isLoading"
:errors="errors"
@submit="handleSubmit"
@cancel="close"
@clear-error="clearFieldError"
/>
</Modal>
</template>
import * as yup from 'yup';
import { useAtom } from 'jotai';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { personalDataStore, stepStore } from '../store/store';
import Header from '../components/section/Header';
import NavButtons from '../components/section/NavButtons';
import TextInput from '../components/forms/inputs/TextInput';
type FormValues = {
name: string;
email: string;
phone: string;
};
const schema = yup.object({
name: yup.string().required('Name is required'),
email: yup
.string()
.required('Email is required')
.test((value, context) => {
return /^\S+@\S+\.\S+$/.test(value) || context.createError({ message: 'E-mail is not valid' });
}),
phone: yup
.string()
.required('Phone is required')
.test((value, context) => {
return /^(\+?420)?(\d?){9}$/.test(value) || context.createError({ message: 'Phone is not valid' });
})
.transform((value) => (value ? value.replace(/\s/g, '') : value)),
});
function PersonalInfo(): JSX.Element {
const [, setStep] = useAtom(stepStore);
const [pData, setPersonalData] = useAtom(personalDataStore);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({ resolver: yupResolver(schema), values: pData });
const onSubmit = handleSubmit((data) => {
setPersonalData(data);
setStep(2);
});
return (
<form onSubmit={onSubmit} className="grid grid-cols-1 place-content-between h-full">
<Header
title="Personal info"
description="Please provide your name, email address, and phone number."
/>
<div className="grid gap-5 mt-5 xl:mt-0">
<TextInput label="Name" fieldName="name" error={errors.name} register={register} />
<TextInput label="E-mail Address" fieldName="email" error={errors.email} register={register} />
<TextInput
label="Phone Number"
fieldName="phone"
placeholder="e.g. +420 123 456 789"
error={errors.phone}
register={register}
/>
</div>
<NavButtons btnText="Next step" />
</form>
);
}
export default PersonalInfo;
import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'
import { CallableContext } from 'firebase-functions/lib/providers/https'
import { ComparatorResult } from './types/ComparatorResult'
import { AllowedTypes } from './types/ScraperType'
import Puppeteer from './scraper/puppeteer'
admin.initializeApp()
type ScraperRequest = {
links: string[]
scraperType: string
}
export const runPuppeteer = functions
.region('europe-west3')
.runWith({
timeoutSeconds: 120,
memory: '4GB',
failurePolicy: false
})
.https.onCall(async (data: ScraperRequest, context: CallableContext): Promise<Array<ComparatorResult>> => {
const comparatorResults: Array<ComparatorResult> = []
const links = data.links
.filter((item, index, self) => self.indexOf(item) === index)
.splice(0, 10)
if (! AllowedTypes.includes(data.scraperType)) {
comparatorResults.push({
error: `Scraper typu '${data.scraperType} neexistuje.'`,
url: null,
duration: null,
listingResult: null,
debugLink: null
})
return comparatorResults
}
const scraper = new Puppeteer(data.scraperType, true, null, 1500)
await scraper.createBrowser(1920, 1080)
for (const targetUrl of links) {
try {
const comparatorResult = await scraper.getPageResult(targetUrl)
comparatorResults.push(comparatorResult)
} catch (exception) {
comparatorResults.push({
error: exception instanceof Error ? exception.message : String(exception),
url: targetUrl,
duration: null,
listingResult: null,
debugLink: null
})
}
}
await scraper.closeBrowser()
return comparatorResults
})
FROM node:24-alpine AS build-stage-node
WORKDIR /build
COPY . ./
RUN yarn cache clean --mirror
RUN yarn && yarn build
FROM php:8.4-fpm-alpine
WORKDIR /var/www/html
# Set timezone
ENV TZ="Europe/Prague"
# Nginx & PHP configs
COPY ./docker/nginx/nginx.conf /etc/nginx/nginx.conf
COPY ./docker/nginx/http.d/default.conf /etc/nginx/http.d/default.conf
COPY ./docker/php/php.ini /usr/local/etc/php/conf.d/php.ini
# Install core linux dependencies
RUN apk add su-exec
RUN apk add openssl curl ca-certificates
RUN apk add bash nano
RUN apk add nginx
# Install opcache
RUN docker-php-ext-install opcache
# Install intl
RUN apk add --no-cache icu-dev
RUN docker-php-ext-configure intl
RUN docker-php-ext-install intl
# Install postgres
RUN apk add --no-cache libpq-dev
RUN docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql
RUN docker-php-ext-install pdo pdo_pgsql
# Install excimer (Sentry)
RUN apk add autoconf g++ make pcre-dev
RUN pecl install excimer
RUN docker-php-ext-enable excimer
# Copy source code
COPY . ./
COPY --from=build-stage-node /build/www/temp ./www/temp
COPY --from=build-stage-node /build/temp/latte-mail ./temp/latte-mail
# Install composer & dependencies
ENV COMPOSER_ALLOW_SUPERUSER=1
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN composer install --no-cache --prefer-dist --no-scripts
# Resolve permissions
RUN chmod -R ugo+w ./temp
RUN chmod -R ugo+w ./log
RUN chmod -R ugo+r ./www/temp
RUN chown -R www-data:www-data /var/www/html
# Add entrypoint
ADD ./docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
.side-modal {
top: 0;
left: 0;
right: 0;
z-index: 1005;
background: rgba(var(--v-theme-on-surface), .32);
transition: 300ms ease opacity;
opacity: 0;
pointer-events: none;
&.opened {
opacity: 1;
pointer-events: auto;
.side-modal-container {
transform: translateX(0);
}
}
&-container {
max-width: 700px;
width: 100%;
z-index: 2;
right: 0;
transition: 300ms ease transform;
transform: translateX(100%);
}
&-close {
left: -55px;
top: 20px
}
}