factor out common api code
This commit is contained in:
parent
1e63e008af
commit
67cad656d5
7 changed files with 239 additions and 201 deletions
|
@ -1,112 +1,10 @@
|
||||||
/* global defineEventHandler getQuery, createError */
|
/* global defineEventHandler getQuery, createError */
|
||||||
import { QueryObject } from "ufo";
|
|
||||||
|
|
||||||
import { data, database } from "../utils/database";
|
import BaaPagination from "~/server/utils/baaPagination";
|
||||||
import { isString } from "../utils/isString";
|
|
||||||
import { client } from "~/utils/types/database";
|
import { client } from "~/utils/types/database";
|
||||||
|
|
||||||
type queryType = {
|
export const baaWrapper = new BaaPagination<client, "id">("clients", "id");
|
||||||
type: "before" | "after" | "around",
|
|
||||||
id: string
|
|
||||||
} | {
|
|
||||||
type: null
|
|
||||||
};
|
|
||||||
|
|
||||||
function getLocationParameterType(query: QueryObject): queryType {
|
export default defineEventHandler((e) => {
|
||||||
const before = query.before;
|
return baaWrapper.RESTget(e);
|
||||||
const after = query.after;
|
|
||||||
const around = query.around;
|
|
||||||
|
|
||||||
let setLocationParametersCount = 0;
|
|
||||||
let rvalue: queryType = { type: null };
|
|
||||||
|
|
||||||
if (isString(before)) {
|
|
||||||
setLocationParametersCount++;
|
|
||||||
rvalue = { type: "before", id: before };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isString(after)) {
|
|
||||||
setLocationParametersCount++;
|
|
||||||
rvalue = { type: "after", id: after };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isString(around)) {
|
|
||||||
setLocationParametersCount++;
|
|
||||||
rvalue = { type: "around", id: around };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (setLocationParametersCount > 1) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: "multiple location parameters not allowed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return rvalue;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getResults(
|
|
||||||
queryType: queryType,
|
|
||||||
limit = 50,
|
|
||||||
) {
|
|
||||||
switch (queryType.type) {
|
|
||||||
case "before": {
|
|
||||||
const [data] = await database.query(
|
|
||||||
"SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` WHERE `id` < ? ORDER BY `id` DESC LIMIT ?",
|
|
||||||
[queryType.id, limit],
|
|
||||||
) as unknown as data<client>;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
case "after": {
|
|
||||||
const [data] = await database.query(
|
|
||||||
"SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` WHERE `id` > ? ORDER BY `id` DESC LIMIT ?",
|
|
||||||
[queryType.id, limit],
|
|
||||||
) as unknown as data<client>;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
case "around": {
|
|
||||||
const [data] = await database.query(
|
|
||||||
"(SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` WHERE `id` >= ? ORDER BY `id` ASC LIMIT ?)\n" +
|
|
||||||
"UNION ALL\n" +
|
|
||||||
"(SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` WHERE `id` < ? ORDER BY `id` DESC LIMIT ?)\n" +
|
|
||||||
"ORDER BY `id` DESC",
|
|
||||||
[queryType.id, Math.ceil(limit / 2), queryType.id, Math.floor(limit / 2)],
|
|
||||||
) as unknown as data<client>;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
case null: {
|
|
||||||
const [data] = await database.query(
|
|
||||||
"SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` ORDER BY `id` DESC LIMIT ?",
|
|
||||||
[limit],
|
|
||||||
) as unknown as data<client>;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw createError("Not implemented");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (e) => {
|
|
||||||
const query = getQuery(e);
|
|
||||||
|
|
||||||
let limit = 50;
|
|
||||||
if (query.limit) limit = Number(query.limit);
|
|
||||||
if (limit > 200) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: "Cannot retrieve more than 200 records",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (limit <= 0) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: "Tried to retireve 0 or less records",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryData = getLocationParameterType(query);
|
|
||||||
|
|
||||||
const result = await getResults(queryData, limit);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
/* global defineEventHandler, createError, readBody */
|
/* global defineEventHandler, createError, readBody */
|
||||||
|
|
||||||
import { database } from "../utils/database";
|
import { baaWrapper } from "./clients.get";
|
||||||
import Snowflake from "../utils/snowflake";
|
|
||||||
import { client } from "~/utils/types/database";
|
import { client } from "~/utils/types/database";
|
||||||
|
|
||||||
const clientKeys = [
|
const clientKeys: Array<string> = [
|
||||||
"name",
|
"name",
|
||||||
"address",
|
"address",
|
||||||
"phone",
|
"phone",
|
||||||
|
@ -13,7 +12,7 @@ const clientKeys = [
|
||||||
|
|
||||||
export function checkIsClient(
|
export function checkIsClient(
|
||||||
value: any,
|
value: any,
|
||||||
required = true,
|
required = false,
|
||||||
): value is Partial<Omit<client, "id">> {
|
): value is Partial<Omit<client, "id">> {
|
||||||
const errors = new Map<string, string>();
|
const errors = new Map<string, string>();
|
||||||
|
|
||||||
|
@ -29,7 +28,7 @@ export function checkIsClient(
|
||||||
if (!(typeof value.phone === "string" || value.phone === null || (!required && value.phone === undefined))) errors.set("phone", "is not string or null");
|
if (!(typeof value.phone === "string" || value.phone === null || (!required && value.phone === undefined))) errors.set("phone", "is not string or null");
|
||||||
if (!(typeof value.email === "string" || value.email === null || (!required && value.email === undefined))) errors.set("email", "is not string or null");
|
if (!(typeof value.email === "string" || value.email === null || (!required && value.email === undefined))) errors.set("email", "is not string or null");
|
||||||
|
|
||||||
for (const i in value)
|
for (const i in value as Partial<Omit<client, "id">>)
|
||||||
if (!clientKeys.includes(i)) errors.set(i, `excessive property`);
|
if (!clientKeys.includes(i)) errors.set(i, `excessive property`);
|
||||||
|
|
||||||
if (errors.size !== 0) {
|
if (errors.size !== 0) {
|
||||||
|
@ -49,19 +48,6 @@ export function checkIsClient(
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (e) => {
|
export default defineEventHandler((e) => {
|
||||||
const body = await readBody(e);
|
return baaWrapper.RESTpost(e, clientKeys as Array<keyof Omit<client, "id">>, (o): o is Omit<client, "id"> => checkIsClient(o, true));
|
||||||
const id = new Snowflake().toString();
|
|
||||||
|
|
||||||
if (!checkIsClient(body)) return; // checkIsClient already throws an detailed error
|
|
||||||
|
|
||||||
await database.query(
|
|
||||||
"INSERT INTO `clients` VALUES (?, ?, ?, ?, ?)",
|
|
||||||
[id, body.name, body.address, body.phone, body.email],
|
|
||||||
);
|
|
||||||
|
|
||||||
// FIXME: data may be turncated in the database
|
|
||||||
// either throw an error when data is too large or
|
|
||||||
// reply with turncated data
|
|
||||||
return { id, ...body };
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,17 +1,7 @@
|
||||||
/* global defineEventHandler, createError */
|
/* global defineEventHandler */
|
||||||
import { ResultSetHeader } from "mysql2";
|
|
||||||
|
|
||||||
import { database } from "~/server/utils/database";
|
import { baaWrapper } from "../clients.get";
|
||||||
|
|
||||||
export default defineEventHandler(async (e) => {
|
export default defineEventHandler((e) => {
|
||||||
const id = e.context.params?.id as string;
|
return baaWrapper.RESTdeleteRecord(e);
|
||||||
|
|
||||||
const [result] = await database.query(
|
|
||||||
"DELETE FROM `clients` WHERE `id` = ?",
|
|
||||||
[id],
|
|
||||||
) as unknown as [ResultSetHeader];
|
|
||||||
|
|
||||||
if (result.affectedRows === 0) throw createError({ statusCode: 404 });
|
|
||||||
|
|
||||||
return null;
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,20 +1,7 @@
|
||||||
/* global defineEventHandler, createError */
|
/* global defineEventHandler */
|
||||||
|
|
||||||
import { database, data } from "~/server/utils/database";
|
import { baaWrapper } from "../clients.get";
|
||||||
import { client } from "~/utils/types/database";
|
|
||||||
|
|
||||||
export default defineEventHandler(async (e) => {
|
export default defineEventHandler((e) => {
|
||||||
const id = e.context.params?.id;
|
return baaWrapper.RESTgetRecord(e);
|
||||||
const [data] = await database.query(
|
|
||||||
"SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` WHERE `id` = ?",
|
|
||||||
[id],
|
|
||||||
) as unknown as data<client>;
|
|
||||||
|
|
||||||
if (!data[0]) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 404,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return data[0];
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,36 +1,8 @@
|
||||||
/* global defineEventHandler, readBody, createError */
|
/* global defineEventHandler */
|
||||||
|
|
||||||
import { ResultSetHeader } from "mysql2";
|
|
||||||
|
|
||||||
import { checkIsClient } from "../clients.post";
|
import { checkIsClient } from "../clients.post";
|
||||||
import { client } from "~/utils/types/database";
|
import { baaWrapper } from "../clients.get";
|
||||||
import { database, data } from "~/server/utils/database";
|
|
||||||
|
|
||||||
export default defineEventHandler(async (e) => {
|
export default defineEventHandler((e) => {
|
||||||
const body = await readBody(e);
|
return baaWrapper.RESTpatchRecord(e, checkIsClient);
|
||||||
const id = e.context.params?.id as string;
|
|
||||||
|
|
||||||
if (!checkIsClient(body, false)) return; // checkIsClient already throws an detailed error
|
|
||||||
|
|
||||||
for (const [k, v] of Object.entries(body)) {
|
|
||||||
const [res] = await database.query(
|
|
||||||
// I believe it is safe to put key in the template
|
|
||||||
// because it is limited to 4 values here
|
|
||||||
`UPDATE \`clients\` SET \`${k}\` = ? WHERE \`id\` = ?`,
|
|
||||||
[v, id],
|
|
||||||
) as unknown as [ResultSetHeader];
|
|
||||||
|
|
||||||
if (res.affectedRows !== 1) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 404,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [data] = await database.query(
|
|
||||||
"SELECT *, CONVERT(`id`, CHAR) AS `id` FROM `clients` WHERE `id` = ?",
|
|
||||||
[id],
|
|
||||||
) as unknown as data<client>;
|
|
||||||
|
|
||||||
return data[0];
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,12 +1,7 @@
|
||||||
/* global defineEventHandler, createError */
|
/* global defineEventHandler */
|
||||||
|
|
||||||
import { database, data } from "~/server/utils/database";
|
import { baaWrapper } from "../clients.get";
|
||||||
|
|
||||||
export default defineEventHandler(async (e) => {
|
export default defineEventHandler((e) => {
|
||||||
const [[data]] = await database.query(
|
return baaWrapper.RESTrecordCount(e);
|
||||||
"SELECT COUNT(*) as `count` FROM `clients`",
|
|
||||||
) as unknown as data<{count: number}>;
|
|
||||||
|
|
||||||
if (!data) throw createError("Database returned no rows");
|
|
||||||
return data;
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,3 +1,12 @@
|
||||||
|
/* global defineEventHandler, getQuery, createError, readBody */
|
||||||
|
import { QueryObject } from "ufo";
|
||||||
|
import { H3Event } from "h3";
|
||||||
|
import { ResultSetHeader } from "mysql2/promise";
|
||||||
|
|
||||||
|
import { data, database } from "./database";
|
||||||
|
import { isString } from "./isString";
|
||||||
|
import Snowflake from "./snowflake";
|
||||||
|
import { client } from "~/utils/types/database";
|
||||||
|
|
||||||
type queryType = {
|
type queryType = {
|
||||||
type: "before" | "after" | "around",
|
type: "before" | "after" | "around",
|
||||||
|
@ -9,11 +18,212 @@ type queryType = {
|
||||||
/**
|
/**
|
||||||
* Before, around, after pagination wrapper
|
* Before, around, after pagination wrapper
|
||||||
*/
|
*/
|
||||||
export default class baaPagination<T> {
|
export default class BaaPagination<T extends {[k: string]: any}, keyType extends string = "id"> {
|
||||||
readonly table: string;
|
readonly table: string;
|
||||||
readonly key: string;
|
readonly key: keyType;
|
||||||
|
|
||||||
constructor(table: string, key: string) {
|
/**
|
||||||
|
* Gets queryType for a given query with a value
|
||||||
|
* @param query the query to parse
|
||||||
|
* @throws if query malformed (multiple before/after/around)
|
||||||
|
*/
|
||||||
|
static getLocationParameterType(query: QueryObject): queryType {
|
||||||
|
const before = query.before;
|
||||||
|
const after = query.after;
|
||||||
|
const around = query.around;
|
||||||
|
|
||||||
|
let setLocationParametersCount = 0;
|
||||||
|
let rvalue: queryType = { type: null };
|
||||||
|
|
||||||
|
if (isString(before)) {
|
||||||
|
setLocationParametersCount++;
|
||||||
|
rvalue = { type: "before", id: before };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isString(after)) {
|
||||||
|
setLocationParametersCount++;
|
||||||
|
rvalue = { type: "after", id: after };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isString(around)) {
|
||||||
|
setLocationParametersCount++;
|
||||||
|
rvalue = { type: "around", id: around };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setLocationParametersCount > 1) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "multiple location parameters not allowed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rvalue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPagedResults(
|
||||||
|
queryType: queryType,
|
||||||
|
limit = 50,
|
||||||
|
) {
|
||||||
|
switch (queryType.type) {
|
||||||
|
case "before": {
|
||||||
|
const [data] = await database.query(
|
||||||
|
`SELECT *, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM \`${this.table}\` WHERE \`${this.key}\` < ? ORDER BY \`${this.key}\` DESC LIMIT ?`,
|
||||||
|
[queryType.id, limit],
|
||||||
|
) as unknown as data<T>;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
case "after": {
|
||||||
|
const [data] = await database.query(
|
||||||
|
`SELECT *, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM \`${this.table}\` WHERE \`$this.key\` > ? ORDER BY \`${this.key}\` DESC LIMIT ?`,
|
||||||
|
[queryType.id, limit],
|
||||||
|
) as unknown as data<T>;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
case "around": {
|
||||||
|
const [data] = await database.query(
|
||||||
|
`(SELECT *, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM \`${this.table}\` WHERE \`${this.key}\` >= ? ORDER BY \`${this.key}\` ASC LIMIT ?)\n` +
|
||||||
|
"UNION ALL\n" +
|
||||||
|
`(SELECT *, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM \`${this.table}\` WHERE \`${this.key}\` < ? ORDER BY \`${this.key}\` DESC LIMIT ?)\n` +
|
||||||
|
"ORDER BY `id` DESC",
|
||||||
|
[queryType.id, Math.ceil(limit / 2), queryType.id, Math.floor(limit / 2)],
|
||||||
|
) as unknown as data<T>;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
case null: {
|
||||||
|
const [data] = await database.query(
|
||||||
|
`SELECT *, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM \`${this.table}\` ORDER BY \`${this.key}\` DESC LIMIT ?`,
|
||||||
|
[limit],
|
||||||
|
) as unknown as data<T>;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw createError("Not implemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RESTget(e: H3Event, defaultLimit = 50, limitLimit = 200) {
|
||||||
|
const query = getQuery(e);
|
||||||
|
|
||||||
|
let limit = defaultLimit;
|
||||||
|
if (query.limit) limit = Number(query.limit);
|
||||||
|
if (limit > limitLimit) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: `Cannot retrieve more than ${limitLimit} records`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (limit <= 0) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Tried to retireve 0 or less records",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryData = BaaPagination.getLocationParameterType(query);
|
||||||
|
|
||||||
|
return this.getPagedResults(queryData, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async RESTpost<K extends keyof Omit<T, keyType>>(
|
||||||
|
e: H3Event,
|
||||||
|
fields: Array<K>,
|
||||||
|
valueChecker: (obj: unknown) => obj is {[P in K]: T[P]},
|
||||||
|
) {
|
||||||
|
const body = await readBody(e);
|
||||||
|
const id = new Snowflake().toString();
|
||||||
|
|
||||||
|
if (!valueChecker(body)) throw createError({ message: "Invalid body", statusCode: 400 });
|
||||||
|
|
||||||
|
const arrayToInsert: Array<any> = [id];
|
||||||
|
arrayToInsert.push(...fields.map(field => body[field]));
|
||||||
|
|
||||||
|
await database.query(
|
||||||
|
"INSERT INTO `clients` " +
|
||||||
|
`(\`${this.key}\`,\`${fields.join("`, `")}\`) ` +
|
||||||
|
"VALUES (" +
|
||||||
|
"?, ".repeat(fields.length) +
|
||||||
|
"?)",
|
||||||
|
arrayToInsert,
|
||||||
|
);
|
||||||
|
|
||||||
|
// FIXME: data may be turncated in the database
|
||||||
|
// either throw an error when data is too large or
|
||||||
|
// reply with turncated data
|
||||||
|
return { id, ...body };
|
||||||
|
}
|
||||||
|
|
||||||
|
async RESTgetRecord(e: H3Event) {
|
||||||
|
const key = e.context.params?.[this.key];
|
||||||
|
const [data] = await database.query(
|
||||||
|
`SELECT *, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM \`${this.table}\` WHERE \`${this.key}\` = ?`,
|
||||||
|
[key],
|
||||||
|
) as data<T>;
|
||||||
|
|
||||||
|
if (!data[0]) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return data[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async RESTpatchRecord(
|
||||||
|
e: H3Event,
|
||||||
|
valueChecker: (obj: unknown) => obj is Partial<Omit<T, keyType>>,
|
||||||
|
) {
|
||||||
|
const body = await readBody(e);
|
||||||
|
const key = e.context.params?.[this.key];
|
||||||
|
|
||||||
|
if (!valueChecker(body)) throw createError({ message: "Invalid body", statusCode: 400 });
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(body)) {
|
||||||
|
// FIXME: use single database.query method instead of looping through keys and values
|
||||||
|
const [res] = await database.query(
|
||||||
|
// I believe it is safe to put key (k) in the template
|
||||||
|
// because it is limited to 4 values here
|
||||||
|
`UPDATE \`${this.table}\` SET \`${k}\` = ? WHERE \`${this.key}\` = ?`,
|
||||||
|
[v, key],
|
||||||
|
) as unknown as [ResultSetHeader];
|
||||||
|
|
||||||
|
if (res.affectedRows !== 1) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data] = await database.query(
|
||||||
|
`SELECT *, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM \`${this.table}\` WHERE \`${this.key}\` = ?`,
|
||||||
|
[key],
|
||||||
|
) as data<T>;
|
||||||
|
|
||||||
|
return data[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async RESTdeleteRecord(e: H3Event) {
|
||||||
|
const key = e.context.params?.[this.key];
|
||||||
|
|
||||||
|
const [result] = await database.query(
|
||||||
|
`DELETE FROM \`${this.table}\` WHERE \`${this.key}\` = ?`,
|
||||||
|
[key],
|
||||||
|
) as unknown as [ResultSetHeader];
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) throw createError({ statusCode: 404 });
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async RESTrecordCount(e :H3Event) {
|
||||||
|
const [[data]] = await database.query(
|
||||||
|
`SELECT COUNT(*) as \`count\` FROM \`${this.table}\``,
|
||||||
|
) as data<{count: number}>;
|
||||||
|
|
||||||
|
if (!data) throw createError("Database returned no rows");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(table: string, key: keyType) {
|
||||||
this.table = table;
|
this.table = table;
|
||||||
this.key = key;
|
this.key = key;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue