Skip to content
This repository was archived by the owner on Mar 20, 2023. It is now read-only.
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
5 changes: 5 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ module.exports = {
files: ['**/*Slice.ts'],
rules: { 'no-param-reassign': 0 },
},
{
// CLI scripts.
files: ['packages/cli/**'],
rules: { 'no-restricted-syntax': 0, 'no-await-in-loop': 0 },
},
{
// Enable plugins rules only for test files.
files: [
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
core
shared
deps
cli
requireScope: false
validateSingleCommit: true
subjectPattern: ^(?![A-Z]).+$
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
PROCESS_ST_CHECKLIST_URL=""

SUPABASE_URL=""
SUPABASE_ANON_KEY=""
20 changes: 20 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@coderscamp/cli",
"version": "0.0.1",
"private": true,
"scripts": {
"invoke": "rimraf dist && tsc && node dist/cli/src/index.js"
},
"dependencies": {
"@supabase/supabase-js": "1.24.0",
"class-transformer": "0.4.0",
"class-validator": "0.13.1",
"commander": "8.2.0",
"csv-parse": "4.16.3",
"dotenv": "10.0.0",
"generate-password": "1.6.1",
"puppeteer": "10.4.0",
"reflect-metadata": "0.1.13",
"winston": "3.3.3"
}
}
66 changes: 66 additions & 0 deletions packages/cli/src/commands/generate-checklists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Command } from 'commander';
import puppeteer from 'puppeteer';

import { getUsersByRole, updateUserById } from '../shared/db';
import { env, validateEnv } from '../shared/env';
import { createLogger } from '../shared/logger';
import { userRoles } from '../shared/models';

const logger = createLogger('generate-checklists');

export const generateProcessStChecklist = async (name: string) => {
logger.debug(`Generating new checklist for the user ${name}`);

const urlBase = env.PROCESS_ST_CHECKLIST_URL;
const url = `${urlBase}?checklist_name=${encodeURIComponent(name)}`;

logger.debug('Launching the Puppeteer browser');

const browser = await puppeteer.launch();

try {
const page = await browser.newPage();

logger.debug('Opening provided URL', { url });
await page.goto(url);

logger.debug('Waiting for checklist to be generated');
await page.waitForSelector('.steps-header');

const checklistUrl = page.url();

logger.debug('Page url retrieved. Closing the browser');
await browser.close();

return checklistUrl;
} catch (ex) {
logger.debug(`An error ocurred when generating the checklist for user ${name}`, ex);
await browser.close();
throw ex;
}
};

export const generateChecklists = (program: Command) => {
program
.command('generate-checklists')
.description('Cerates checklists on Process.st for all participants')
.action(async () => {
try {
await validateEnv();

const participants = await getUsersByRole(userRoles.participant);

logger.debug('Iterating through fetched participants');

for (const participant of participants) {
const checklist = await generateProcessStChecklist(participant.name);

await updateUserById(participant.id, { checklist });
}

logger.debug('Iteration through fetched participants finished');
} catch (ex) {
logger.error(ex);
}
});
};
48 changes: 48 additions & 0 deletions packages/cli/src/commands/register-participants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Argument, Command } from 'commander';

import { getCsvContent } from '../shared/csv';
import { insertUsers, register } from '../shared/db';
import { validateEnv } from '../shared/env';
import { createLogger } from '../shared/logger';
import { ParticipantCsvRow, RegisterDTO, User, userRoles } from '../shared/models';
import { transformToMatchClass } from '../shared/object';

const logger = createLogger('register-participants');

export const registerParticipants = (program: Command) => {
program
.command('register-participants')
.description('Creates accounts for CodersCamp participants listed in the CSV file')
.addArgument(new Argument('<csv-path>', 'Path to the CSV file'))
.action(async (csvPath: string) => {
try {
await validateEnv();

const rows = await getCsvContent(csvPath);
const participantsRows = await Promise.all(rows.map(transformToMatchClass(ParticipantCsvRow)));

const participants: User[] = [];

logger.debug('Iterating through parsed rows');

for (const { email, firstName, lastName } of participantsRows) {
const registerDto = await transformToMatchClass(RegisterDTO)({ email });
const userId = await register(registerDto);
const participant = await transformToMatchClass(User)({
...registerDto,
id: userId,
name: `${firstName} ${lastName}`,
role: userRoles.participant,
});

participants.push(participant);
}

logger.debug('Iteration through parsed rows finished');

await insertUsers(participants);
} catch (ex) {
logger.error(ex);
}
});
};
15 changes: 15 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import 'reflect-metadata';

import { Command } from 'commander';

import { generateChecklists } from './commands/generate-checklists';
import { registerParticipants } from './commands/register-participants';

const program = new Command();

program.version('0.0.0');

registerParticipants(program);
generateChecklists(program);

program.parse();
20 changes: 20 additions & 0 deletions packages/cli/src/shared/csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import parse from 'csv-parse/lib/sync';
import { readFile } from 'fs/promises';

import { createLogger } from './logger';

const logger = createLogger('CSV Utils');

export const getCsvContent = async (csvPath: string) => {
logger.debug(`reading CSV file for path ${csvPath}`);

const content = await readFile(csvPath, { encoding: 'utf-8' });

logger.debug('parsing content of the CSV file');

const parsedContent: Record<string, unknown>[] = parse(content, { columns: true });

logger.debug('CSV file content parsed successfully');

return parsedContent;
};
59 changes: 59 additions & 0 deletions packages/cli/src/shared/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { createClient } from '@supabase/supabase-js';
import { generate } from 'generate-password';

import { env } from './env';
import { createLogger } from './logger';
import { RegisterDTO, Role, User } from './models';

const logger = createLogger('DB Utils');

const USERS_TABLE_NAME = 'users';

const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY);

export const register = async (registerDto: RegisterDTO): Promise<User['id']> => {
logger.debug(`Registering user with email ${registerDto.email}`);

const { user, error } = await supabase.auth.signUp({
email: registerDto.email,
password: generate({ length: 16, numbers: true, symbols: true }),
});

if (!user) {
throw error ?? new Error(`Unknown error ocurred when signing up user with email ${registerDto.email}`);
}

logger.debug(`User with email ${registerDto.email} registered`, { id: user.id });

return user.id;
};

export const getUsersByRole = async (role: Role) => {
logger.debug(`Fetching users with the ${role} role`);

const { data, error } = await supabase.from<User>(USERS_TABLE_NAME).select().eq('role', role);

if (!data) {
throw new Error(error ? error.message : `Unknown error ocurred when getting users from the database`);
}

logger.debug(`Users with the ${role} role fetched successfully`, data);

return data;
};

export const insertUsers = async (users: User[]) => {
logger.debug(`Inserting provided users to the ${USERS_TABLE_NAME} table`, users);

await supabase.from<User>(USERS_TABLE_NAME).insert(users);

logger.debug(`Users inserted to the ${USERS_TABLE_NAME} table`);
};

export const updateUserById = async (id: User['id'], data: Partial<Omit<User, 'id' | 'password'>>) => {
logger.debug(`Updating user with id ${id} using the provided data`, data);

await supabase.from<User>(USERS_TABLE_NAME).update(data).match({ id });

logger.debug(`User with id ${id} updated successfully`);
};
63 changes: 63 additions & 0 deletions packages/cli/src/shared/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Expose, plainToClass } from 'class-transformer';
import { IsInt, IsNotEmpty, IsString, validateOrReject } from 'class-validator';
import dotenv from 'dotenv';

import { createLogger } from './logger';

const logger = createLogger('Env Utils');

dotenv.config();

class EnvVariables {
@Expose()
@IsString()
@IsNotEmpty()
SUPABASE_URL: string;

@Expose()
@IsString()
@IsNotEmpty()
SUPABASE_ANON_KEY: string;

@Expose()
@IsString()
@IsNotEmpty()
PROCESS_ST_CHECKLIST_URL: string;

@Expose()
@IsInt()
// @IsNotEmpty()
NODEMAILER_PORT: number;

@Expose()
@IsString()
// @IsNotEmpty()
NODEMAILER_HOST: string;

@Expose()
@IsString()
// @IsNotEmpty()
NODEMAILER_USER: string;

@Expose()
@IsString()
// @IsNotEmpty()
NODEMAILER_PASSWORD: string;
}

export const env = plainToClass(EnvVariables, process.env, {
excludeExtraneousValues: true,
enableImplicitConversion: true,
});

export const validateEnv = async () => {
logger.debug('Validating env variables');

try {
await validateOrReject(env);
logger.debug('Env variables validated successfully');
} catch (ex) {
logger.error('Error when validating env variables');
throw ex;
}
};
29 changes: 29 additions & 0 deletions packages/cli/src/shared/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import winston from 'winston';

const logger = winston.createLogger({
level: 'debug',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.metadata({ fillExcept: ['message', 'level', 'timestamp', 'label'] }),
),

transports: [
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
format: winston.format.combine(winston.format.json()),
}),
new winston.transports.File({
filename: 'logs/combined.log',
format: winston.format.combine(winston.format.json()),
}),
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf((info) => `${info.timestamp} ${info.level} [${info.label}]: ${info.message}`),
),
}),
],
});

export const createLogger = (label: string) => logger.child({ label });
Loading