I share my experience of participating in a hackathon from Sovcombank

Skolkovo from the inside at 17:00

Skolkovo from the inside at 17:00

Today, I want to share my experience and talk about participating in a team hackathon from sovcombank. I will briefly describe the task – a team of up to 5 people to create an internal service for recruiting and conducting HR activities. Who is interested in the experience of participation and a little underside of hackathons – please under the cut)


Preparation is half the battle

At the first stage, I decided to assemble a team from the participants of the last hackathon in which I participated (DatsArt ranked 46 out of 340) and threw a cry in the telegram chat, immediately the composition turned out to be the following: 2 frontend + 2 python + 1 java developers. Since we were going to write an http application and our plans did not include writing a REST API in java spring or python, I switched to the role of a backender, which will make APIs for the web muzzle and CRUD operations on the database on php 8.2 + symfony, the front will make interfaces on vue 3.3 + TS + pinia, pythonists will be responsible for the algorithm for finding and calculating relevance in the submitted resumes, and the javaist will write a layer that will search for resumes on third-party sites like hh or superjob. Additional services were planned as separate HTTP services in their own containers. More on that later.

There was enough time before the hackathon to prepare, I managed to read a little about development on Symfony and try on the role of a back-end developer (those who know me know that for the last 10 years I have been working as a front-end developer / team leader and for me the position of a back-end developer at symphony is new) Why didn’t I choose js \ ts, which is almost native to me? – the purpose of the hackathon is not just to show “see how I can on the node”, but also to provide a working project that will not be ashamed to support in the enterprise and interview people not on their bike on the node, so Symfony with its documentation, doctrine and other features – seemed to me great option. I also purchased VDS for each developer for the duration of the hackathon + mysql database as a service.

Fun starts

Apart from the unusual role for me. At the last QA session before the hackathon, the organizers conveyed the idea that DDESIGN_PRESENTATION IS VERY IMPORTANT and I, as the captain of the team, made an inconvenient decision for one of the python developers – to replace it with a designer. Voluntarily, no one wanted to rent a place, but I didn’t want to lose, and I had to make such a decision. So we were joined by an interface designer who applied on the CodenRock platform according to our announcement.

Half way to victory

On the very first day of the start of the hackathon, after reading the task (and it coincided 90% with the spoiler of the task), the second python developer tells us that he does not want to let the team down and will not take us out and leaves us) I ask the organizers of the competition to return the first python developer to us, such here is santabarbara) But we are still in full force at the start of the hackathon.

The main entities in our project were Vacancies, Resumes, Events (meetings, interviews), applications for approval

  • Vacancies – whom, where, for how much we are looking for

  • Resume – we store the resumes of candidates in our database for reporting and archiving, we allow you to select candidates for a vacancy from the available resumes

  • Events – abstract events – start date, participants, type of event (meeting, negotiation, interview)

  • Departments – an entity with a title and the ability to attach user accounts to it

  • Skills – key skills that pass like tags through users, vacancies, resumes

  • Users – with roles and access levels, indicating in which department they work and what skills they have.

The work logic is very simple, we create a vacancy, appoint a responsible person, the responsible person adds a resume, selects candidates, makes appointments and approvals + build schedules and all sorts of reports as much as we can for a beautiful dashboard.

Half way to victory

On the third day of the hackathon, we had a working swagger the main screens for CRUD operations on entities, having worked a total of 8 hours on the code. Below I will give an example of the controller that I got (they all turned out to be almost the same). I couldn’t work anymore. I did not take days off for the hackathon and worked full-time at my main job.

ApiSkillsController.php example
<?php

namespace App\Controller;

use App\Entity\Skill;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Request;
use OpenApi\Attributes as OAT;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use App\Service\ApiFrontendService;

class ApiSkillsController extends AbstractController
{
    private ApiFrontendService $apiFrontendService;
    public function __construct(EntityManagerInterface $entityManager, ValidatorInterface $validator) {
        $this->apiFrontendService = new ApiFrontendService($entityManager, $validator);
    }
    /**
     * Список навыков
     */
    #[OAT\Get(
        path: '/api/skills',
        security: ['X-AUTH-TOKEN'],
        operationId: 'app_api_skill',
        description: 'Список всех навыков',
        tags: ['Skills'],
        responses: [
            new OAT\Response(
                response: 200,
                description: 'All skills',
                content: new OAT\JsonContent(
                    type: 'array',
                    items: new OAT\Items(ref: "#/components/schemas/Skill")
                )
            ),
        ]
    )]
    #[IsGranted('ROLE_USER')]
    #[Route('/api/skills', name: 'app_api_skill', methods: ['GET'])]
    public function index(): JsonResponse
    {
        return $this->apiFrontendService->getAllEntity('App\Entity\Skill');
    }

    /**
     * Создание навыка.
     */
    #[OAT\Post(
        path: '/api/skills',
        security: ['X-AUTH-TOKEN'],
        operationId: 'app_api_skill_create',
        description: 'Заведение нового навыка',
        tags: ['Skills'],
        parameters: [
            new OAT\RequestBody(
                required: true,
                content: new OAT\JsonContent(ref: "#/components/schemas/Skill")
                   
            )],
        responses: [
            new OAT\Response(
                response: 200,
                description: 'Entity созданного навыка',
                content: new OAT\JsonContent(
                    type: 'array',
                    items: new OAT\Items(ref: "#/components/schemas/Skill")
                )
            ),
        ]
    )
    ]
    #[IsGranted('ROLE_USER')]
    #[Route('/api/skills', name: 'app_api_skill_create', methods: ['POST'])]
    public function create(Request $request, EntityManagerInterface $em, ValidatorInterface $validator): JsonResponse
    {
        $rq = json_decode($request->getContent());
        $skill = new Skill();
        $skill->setTitle($rq->title);
       
        $errors = $validator->validate($skill);
        if (count($errors) > 0) {
            return new JsonResponse([
                'errors' => array_map(function ($error) {
                    return [
                        'property' => $error->getPropertyPath(),
                        'message' => $error->getMessage()
                    ];
                }, iterator_to_array($errors))
            ]);
        }
        $em->getRepository(Skill::class)->save($skill, true);

        return new JsonResponse([
            'data' => $skill->asArray(),
            'errors' => []
        ]);
    }

    /**
     * Редактирование навыка.
     */
    #[OAT\Put(
        path: '/api/skills/{id}',
        security: ['X-AUTH-TOKEN'],
        operationId: 'app_api_skill_edit',
        description: 'Редактирование навыка',
        tags: ['Skills'],
        parameters: [
            new OAT\RequestBody(
                required: true,
                content: new OAT\JsonContent(ref: "#/components/schemas/Skill")
                   
            )],
        responses: [
            new OAT\Response(
                response: 200,
                description: 'Entity навыка',
                content: new OAT\JsonContent(
                    type: 'array',
                    items: new OAT\Items(ref: "#/components/schemas/Skill")
                )
            ),
        ]
    )]
    #[IsGranted('ROLE_USER')]
    #[Route('/api/skills/{id}', name: 'app_api_skill_edit', methods: ['PUT'])]
    public function editDepartament(int $id, Request $request, EntityManagerInterface $em, ValidatorInterface $validator): JsonResponse
    {
        $rq = json_decode($request->getContent());
        $repo = $em->getRepository(Skill::class);
        $departament = $repo->findOneBy([
            'id' => $id
        ]);
        if(!$departament) {
            return new JsonResponse([
                'errors' => ['Invalid ID']
            ]);
        }
        $departament->setTitle($rq->title);
        $errors = $validator->validate($departament);
        if (count($errors) > 0) {
            return new JsonResponse([
                'errors' => array_map(function ($error) {
                    return [
                        'property' => $error->getPropertyPath(),
                        'message' => $error->getMessage()
                    ];
                }, iterator_to_array($errors))
            ]);
        }
        $repo->save($departament, true);
        
        return new JsonResponse([
            'data' => $departament->asArray(),
            'errors' => []
        ]);
    }

    /**
     * Получение навыка по ID
     */
    #[OAT\Get(
        path: '/api/skills/{id}',
        description: 'Получение навыка по ID',
        tags: ['Skills'],
        responses: [
            new OAT\Response(
                response: 200,
                description: 'Departament entity',
                content: new OAT\JsonContent(ref: "#/components/schemas/Skill")
            ),
        ]
    )]
    #[IsGranted('ROLE_USER')]
    #[Route('/api/skills/{id}', methods: ['GET'])]
    public function get(int $id): JsonResponse
    {
        return $this->apiFrontendService->getEntityById('App\Entity\Skill', $id);
    }
}

Loved to use OpenApi\Attributes – you write attributes right next to the code and the documentation is built by itself! Very cool! If someone tells on Habré how to make it so that Shemas generated from Entity and they did not have to be written in nelmio_api_doc.yamlit will be very good) Also, to reduce the code, I made a simple service for typical operations

ApiFrontendService.php
<?php
namespace App\Service;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * Frontend сервис для унификации ответов JSON
 */
class ApiFrontendService
{
    private EntityManagerInterface $entityManager;
    private ValidatorInterface $validator;
    
    public function __construct(EntityManagerInterface $entityManager, ValidatorInterface $validator)
    {
        $this->entityManager = $entityManager;
        $this->validator = $validator;
    }

    /**
     * Получение Entity по Entity.ID
     * @return JsonResponse(Entity[])
     */
    public function getAllEntity(string $className): JsonResponse
    {
        return new JsonResponse(
            array_map(function($entity) {
                return $entity->asArray();
            }, $this->entityManager->getRepository($className)->findAll())
        );
    }

    /**
     * Получение Entity по критерию
     * @return JsonResponse(Entity[])
     */
    public function getEntityListByCriteria(string $className, array $criteria): JsonResponse
    {
        return new JsonResponse(
            array_map(function($entity) {
                return $entity->asArray();
            }, $this->entityManager->getRepository($className)->findBy($criteria))
        );
    }

    /**
     * Получение Entity по наличию memberValue в memberProp 
     * @return JsonResponse(Entity[])
     */
    public function getEntityMembersColllection(string $className, string $propName, int $memberValue): JsonResponse
    {
        $members = $this->entityManager->getRepository($className)->createQueryBuilder('e')
            ->where(':memeber_value MEMBER OF e.'.$propName)
            ->setParameter('memeber_value', $memberValue)
            ->getQuery()
            ->getResult();
        if(!$members) {
            return new JsonResponse(null, 200);
        }
        return new JsonResponse(array_map(function($entity) {
            return $entity->asArray();
        }, $members));
    }

    /**
     * Получение Entity по Entity.ID
     * @return JsonResponse(Entity)
     */
    public function getEntityById(string $className, int $id): JsonResponse
    {
        $entity = $this->entityManager->getRepository($className)->findOneBy([
            'id' => $id
        ]);
        if (!$entity) {
            return new JsonResponse(null, 404);
        }
        return new JsonResponse($entity->asArray());
    }

    /**
     * Сохранние сущности
     * @return JsonResponse(Entity|Errors[{property:string,message:string}])
     */
    public function saveEntity(string $className, $entity): JsonResponse
    {
        $errors = $this->validator->validate($entity);

        if (count($errors) > 0) {
            return new JsonResponse([
                'errors' => array_map(function ($error) {
                    return [
                        'property' => $error->getPropertyPath(),
                        'message' => $error->getMessage()
                    ];
                }, iterator_to_array($errors))
            ], 400);
        }
        
        $this->entityManager->getRepository($className)->save($entity, true);

        return new JsonResponse($entity->asArray(), 200);
    }
}

There was another option to use api-platformbut he scared me with his rules for customizing it in addition to CRUD, there is too much code to describe 1 action for the API, the profit with CRUD generation does not interrupt the overhead with fussing with this bundle in the future, at least during my hackathon)

On the front, besides the molds, everything worked through a set of pinia stores, an example of one of the stores on the front. Otherwise, the usual bootstrap, I see no reason to show the listing. The link to click our result is at the end of the article.

UsersStore.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
import http from '@/http';
export const useUsersStore = defineStore('users', () => {
    /**
     * Список пользователей
     */
    const list = ref([] as any[]);

    /**
     * Загрузка списка пользователей
     * @returns Promise<AxiosResponse> with Users[] entity
     */
    const fetchList = async function fetchList():Promise<any[]> {
        const response = await http.get('/api/users');
        if(response.status === 200 && response.data) {
            list.value = response.data;
        }
        return response.data;
    };

    /**
     * Получение профиля пользователя по ID
     * @param id User.id
     * @returns Promise<AxiosResponse> with User entity
     */
    const fetchUser = async function fetchUser(id:number):Promise<any> {
        return await http.get(`/api/user/${id}`);
    };
    
    /**
     * Создание нового пользователя
     * @param user object
     * @returns Promise<AxiosResponse> with User entity
     */
    const create = async (user:any):Promise<any> => await http.post('/api/users/new', user);

    /**
     * Редактирование пользователя
     * @param id number
     * @param user object
     * @returns Promise<AxiosResponse> with User entity
     */
    const update = async (id:number, user:any):Promise<any> => await http.put(`/api/users/${id}`, user);

    /**
     * Фильтр по пользователю
     * @param id number userId
     * @returns Users[]
     */
    const getUserById = (id:Number) => {
        return list.value.filter(u => u.id === id)[0];
    }

  return { list, fetchList, fetchUser, create, update, getUserById }
});

For the first 3 days, I didn’t really synchronize with the team, focusing on issuing API documentation and integrating with their services as soon as possible. On the third day, wondering what the guys (python and java) did, I find out what they did – nothing. And they will not do anything in general. I did not find out the reasons, nor did I discuss their decision. At this moment, I understand that there are now conditionally three of us) a designer, a frontend and me. I decide to continue working, but I already take on some of the interfaces associated with creating a vacancy and filling them with fixtures, I switch my attention to ensuring that at least the screens in the minimum form we all have and work or show something. In place of an external service in python, we take mocks from stats.hh.ru and build pseudo-analytics already, partly on mocks and partly on real data (which we ourselves added to the service)

The day before the final

The day before the end of the hackathon, we had everything ready in a very raw form, the role model remained from the unfinished parts (to differentiate access for users because the entire hackathon sat under the admin), it was necessary to make a mini summary (funnel) on the job page

Job View Example
Job View Screen

Job View Screen

List of CVs

Resume list screen

Resume list screen

User start screen

User start screen

During the hackathon, the designer drew an SVG logo and prepared a pack of avatars generated by a neural network. The rest of the design is just bootstrap.

Final and summing up

Brief hackathon statistics

Brief hackathon statistics

Of the 216 teams that eventually mastered 42 solutions, 13 made it to the finals, and our solution got into the TOP 10 in 9th place. Of the strengths of our solution, they noted the ease of raising the project (npm run devat the front and symfony server:startfor backend) Availability of `docker-compose` file for deployment and taskfile.yaml as a general set of commands for the repository. Also noted the approach to authorization through X-AUTH-TOKEN in the headers, we moved the implementation of the token itself out of the scope of the project and implied that the token would be given to us by an external authorization service within the banking system. Of the weak ones – the general underdevelopment of analytics and the funnel of candidates, the poor development of the role model. You can poke interfaces on the demo stand mehunt.ru. Of the gifts for the top 10 participants, they presented a set of merchandise – a sweatshirt \ backpack \ bag for a belt and a T-shirt – but in fact they gave everyone only bags for a belt and T-shirts + a diploma and a package) it seems like little things, but in general this is the only moment that confused me in organizations. Skolkovo itself was also embarrassing, such a huge city that is like an abandoned, but not abandoned, just an empty shopping center with rare onlookers, but this has nothing to do with the hackathon itself.

A few words about the winning team – in my opinion, the deserved and the only participants who approached the solution of the problem so seriously and completed everything on time with a bunch of chips. The guys took vacations from their main job, hacked a lot and slept little) + were worked out in other hackathons. So a well-deserved first place by a decent margin.

I will be happy to answer your questions and comments) as well as find those who want to take part in hackathons in any role, well, or I’m ready to join you) welcome in tg @dstrokov

ps The post about the release of the wc-wysiwyg web component will be very soon, the public draft of the post is already available on webislife.ru, and the release itself in the git is already 1.0.4 🙂

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *