Source

classes/animation.ts

import { AnimationResolvable } from "../types/animation";
import Snowflake from "./snowflake";
import UserClient from "./userClient";
import User from "./user";
import Comment from "./comment";
import Collection from "@discordjs/collection";
import { SnowflakeString } from "../types/snowflake";
import Category from "./category";
import Rating from "./rating";
import IFilters from "../types/filters";
import Filters from "./filters";
import { UserResolvable } from "../types/user";
import { RequestContext, RequestContextWithClient } from "./requestContext";
import { UserType } from "../types/basicUser";
import { CommentState } from "../types/comments";
import Language, { LanguageCodes } from "../types/language";
import { DBAnimation, DBAnimationFull, prisma } from "../../prisma";

/**
 * Holds animation data without methods
 */
export class BasicAnimation {
    id: Snowflake;
    ctx: RequestContext;
    author: User;
    data: string;
    category: Category;
    published?: Date;
    name: string;
    description: string;
    length: number;
    language: Language;
    rating?: number | null;
    ratingCount?: number;
    views?: number;

    /**
     * Creates new animation data object
     * @param {object} data
     */
    constructor({
        id,
        ctx,
        author,
        data,
        category,
        name,
        description,
        length,
        published,
        rating,
        ratingCount,
        views,
        language = Language.EN
    }: {
        id: Snowflake,
        ctx: RequestContext,
        author: User,
        data: string,
        category: Category,
        name: string,
        description: string,
        length: number,
        published?: Date,
        rating?: number | null,
        ratingCount?: number,
        views?: number,
        language?: Language
    }) {
        this.id = id;
        this.ctx = ctx;
        this.author = author;
        this.data = data;
        this.category = category;
        this.name = name;
        this.description = description;
        this.length = length;
        this.published = published;
        this.rating = rating;
        this.ratingCount = ratingCount;
        this.views = views;
        this.language = language;
    }

    /**
     * Returns if the param is animation or not. Used for TS.
     * @param {AnimationResolvable} animation
     * @return {boolean}
     */
    static isAnimation(animation: AnimationResolvable):
        animation is Animation | BasicAnimation {
        return animation instanceof BasicAnimation;
    }

    /**
     * Resolves params to string
     * @param {UserResolvable} animation
     * @return {SnowflakeString}
     */
    static resolveAnimationID(animation: AnimationResolvable): SnowflakeString {
        if(Animation.isAnimation(animation)) return animation.id.toString();
        return animation.toString();
    }

    /**
     * @param {DBAnimation | DBAnimationFull} data
     * @param {RequestContext} ctx
     * @return {Promise<BasicAnimation>}
     */
    static async fromDatabase(data: DBAnimation | DBAnimationFull, ctx: RequestContext):
    Promise<BasicAnimation> {
        if(
            !data.published &&
            data.authorId !== ctx.client?.user.id.toBigInt() &&
            (ctx.client && ctx.client.user.type >= UserType.MODERATOR)
        ) {
            throw new Error("not_published");
        }

        if(ctx.animations.has(data.snowflake.toString())) {
            return ctx.animations.get(data.snowflake.toString()) as BasicAnimation;
        }

        return new BasicAnimation({
            id: new Snowflake(data.snowflake),
            ctx,
            author: "author" in data ?
                await User.fromDatabase(data.author, ctx) :
                await User.getUser(data.authorId, ctx),
            data: data.data,
            category: "category" in data ?
                await Category.fromDatabase(data.category, ctx) :
                await Category.getCategory(data.categoryId, ctx),
            description: data.description,
            length: data.length,
            name: data.name,
            published: data.published || undefined,
            language: data.languageId,
            views: data.views
        });
    }

    /**
     * Fetches animation by snowflake from DB
     * @param {AnimationResolvable} id
     * @param {RequestContext} ctx
     * @return {Promise<BasicAnimation>}
     */
    static async getAnimation(id: AnimationResolvable, ctx: RequestContext):
    Promise<BasicAnimation> {
        if(Animation.isAnimation(id)) return id satisfies BasicAnimation;
        id = new Snowflake(id);
        const animation = await prisma.animation.findUniqueOrThrow({
            where: {
                snowflake: id.toBigInt()
            },
            include: {
                author: true,
                category: true
            }
        }) satisfies DBAnimationFull;
        var anim = await this.fromDatabase(animation, ctx);
        anim.rating = await anim.getAverageRating();
        anim.ratingCount = await anim.getRatingCount();
        return anim;
    }

    /**
     * Expands animation to full animation
     * @param {AnimationResolvable} id
     * @param {RequestContextWithClient} ctx
     */
    static async getFullAnimation(id: AnimationResolvable, ctx: RequestContextWithClient):
    Promise<Animation> {
        var anim = await Animation.getAnimation(id, ctx);
        return new Animation({
            ...anim,
            ctx
        });
    }

    /**
     * Fetches all animations from DB
     * @param {any} data
     */
    static async newAnimation(data: {
        id: Snowflake,
        ctx: RequestContextWithClient,
        data: string,
        category: Category,
        name: string,
        description: string,
        length: number,
        language?: Language,
        published?: Date
    }): Promise<Animation> {
        const animation = await prisma.animation.create({
            data: {
                snowflake: data.id.toBigInt(),
                authorId: data.ctx.user!.id.toBigInt(),
                data: data.data,
                categoryId: data.category.id.toBigInt(),
                name: data.name,
                description: data.description,
                published: data.published,
                length: data.length,
                languageId: data.language
            }
        }) satisfies DBAnimation;
        const anim = await Animation.fromDatabase(animation, data.ctx);
        return Animation.getFullAnimation(anim, data.ctx);
    }

    /**
     * Fetches comments on the animation
     * @param {number} max items per page
     * @param {number} page
     * @return {Promise<Collection<SnowflakeString, Comment>>}
     */
    async getComments(max: number, page: number): Promise<Collection<SnowflakeString, Comment>> {
        const comments = await prisma.comment.findMany({
            where: {
                animationId: this.id.toBigInt(),
                state: this.ctx.user && this.ctx.user.type >= UserType.MODERATOR ?
                    undefined :
                    CommentState.NORMAL
            },
            orderBy: {
                snowflake: "desc"
            },
            include: {
                author: true
            },
            skip: page*max,
            take: max
        });

        const col = new Collection<SnowflakeString, Comment>();
        const reported = new Set<bigint>();
        await Promise.all([
            (async () => {
                for(const comment of comments) {
                    col.set(
                        comment.snowflake.toString(),
                        await Comment.fromDatabase(comment, this.ctx)
                    );
                }
            })(), (async () => {
                if(this.ctx.user && this.ctx.user.type >= UserType.MODERATOR) {
                    const reports = await prisma.modQueue.findMany({
                        where: {
                            resourceId: {
                                in: comments.map((comments) => comments.snowflake)
                            }
                        }
                    });
                    for(const report of reports) {
                        reported.add(report.resourceId);
                    }
                }
            })()
        ]);

        for(const report of reported) {
            const comment = col.get(report.toString());
            if(comment) comment.reported = true;
        }

        return col;
    }


    /**
     * Fetches comments on the animation
     * @return {Promise<Cnumber>}
     */
    getCommentCount(): Promise<number> {
        return prisma.comment.count({
            where: {
                animationId: this.id.toBigInt(),
                state: this.ctx.user && this.ctx.user.type >= UserType.MODERATOR ?
                    undefined :
                    CommentState.NORMAL
            }
        });
    }

    /**
     * Fetches ratings on the animation
     * @param {number} max items per page
     * @param {number} page
     * @return {Promise<Collection<SnowflakeString, Rating>>}
     */
    async getRatings(max: number, page: number): Promise<Collection<SnowflakeString, Rating>> {
        const ratings = await prisma.rating.findMany({
            where: {
                animationId: this.id.toBigInt()
            },
            include: {
                author: true
            },
            skip: page*max,
            take: max
        });

        var col = new Collection<SnowflakeString, Rating>();
        for(const rating of ratings) {
            col.set(rating.snowflake.toString(), await Rating.fromDatabase(rating, this.ctx));
        }
        return col;
    }

    /**
     * @return {Promise<number|null>}
     */
    async getAverageRating(): Promise<number | null> {
        const result = await prisma.rating.aggregate({
            where: {
                animationId: this.id.toBigInt()
            },
            _avg: {
                stars: true
            }
        });
        return result._avg.stars;
    }

    /**
     * @return {Promise<number>}
     */
    getRatingCount(): Promise<number> {
        return prisma.rating.count({
            where: {
                animationId: this.id.toBigInt()
            }
        });
    }

    /**
     * @return {Promise<void>}
     */
    async registerView(): Promise<void> {
        await prisma.animation.update({
            where: {
                snowflake: this.id.toBigInt()
            },
            data: {
                views: {
                    increment: 1
                }
            }
        });
    }

    /**
     * Returns a rating from given user, if any
     * @param {UserResolvable} user
     * @return {Promise<Rating | null>}
     */
    getRatingFrom(user: UserResolvable): Promise<Rating | null> {
        return Rating.getUserRating(user, this, this.ctx);
    }


    toMinimal(includeAuthor: false): {
        id: string;
        created: Date;
        category: any;
        rating: number | undefined;
        ratingCount: number | undefined;
        name: string;
        published: Date | undefined;
        length: number;
        language: typeof LanguageCodes[keyof typeof LanguageCodes];
        views: number | undefined;
    }
    toMinimal(includeAuthor: true): {
        id: string;
        created: Date;
        category: any;
        rating: number | undefined;
        ratingCount: number | undefined;
        author: {
            id: string;
            avatar: string | undefined;
            registered: Date;
            name: string;
        };
        name: string;
        published: Date | undefined;
        length: number;
        language: typeof LanguageCodes[keyof typeof LanguageCodes];
        views: number | undefined;
    }

    /**
     * Returns minimum animation representation
     * @param {boolean} includeAuthor
     * @return {object}
     */
    toMinimal(includeAuthor: boolean): {
        id: string;
        created: Date;
        category: any;
        rating: number | undefined | null;
        ratingCount: number | undefined;
        author?: {
            id: string;
            avatar: string | undefined;
            registered: Date;
            name: string;
        };
        name: string;
        published: Date | undefined;
        length: number;
        language: typeof LanguageCodes[keyof typeof LanguageCodes];
        views: number | undefined;
    } {
        if(includeAuthor === true) {
            return {
                id: this.id.getSnowflake(),
                created: this.id.time,
                rating: this.rating,
                ratingCount: this.ratingCount,
                category: this.category.toJSON(),
                author: this.author.toJSON(),
                name: this.name,
                length: this.length,
                published: this.published,
                language: LanguageCodes[this.language],
                views: this.views
            };
        }

        return {
            id: this.id.getSnowflake(),
            created: this.id.time,
            rating: this.rating,
            ratingCount: this.ratingCount,
            category: this.category.toJSON(),
            name: this.name,
            length: this.length,
            published: this.published,
            language: LanguageCodes[this.language],
            views: this.views
        };
    }

    /**
     * Converts animations to JSON-compatible object for saving resources
     * @return {Object}
     */
    toJSON() {
        return {
            id: this.id.getSnowflake(),
            created: this.id.time,
            rating: this.rating,
            ratingCount: this.ratingCount,
            author: this.author.toJSON(),
            data: this.data,
            category: this.category.toJSON(),
            name: this.name,
            description: this.description,
            published: this.published,
            length: this.length,
            language: this.language,
            views: this.views
        };
    }

    /**
     * Counts animations matching given filters
     * @param {RequestContext} ctx
     * @param {IFilters} ifilters
     * @return {Promise<number>}
     */
    static countAnimations(ctx: RequestContext, ifilters: IFilters) : Promise<number> {
        var filters: Filters = ifilters instanceof Filters ? ifilters : new Filters(ifilters, ctx);
        return new Promise((resolve, reject) => {
            var query = filters.buildCountQuery();
            ctx.conn.query(query.query, query.variables, async (err, results) => {
                if(err) return reject(err);
                resolve(results[0].count);
            });
        });
    }

    /**
     * Gets animations that fit into the specified filters
     * @param {RequestContext} ctx
     * @param {IFilters} ifilters
     * @return {Promise<Collection<SnowflakeString, BasicAnimation>>}
     */
    static getAnimations(ctx: RequestContext, ifilters: IFilters):
        Promise<Collection<SnowflakeString, BasicAnimation>> {
        var filters: Filters = ifilters instanceof Filters ? ifilters : new Filters(ifilters, ctx);
        return new Promise((resolve, reject) => {
            var query = filters.buildQuery();
            ctx.conn.query(query.query, query.variables, async (err, results) => {
                if(err) return reject(err);
                var col = new Collection<SnowflakeString, BasicAnimation>();

                for(var result of results) {
                    col.set(result.snowflake, new BasicAnimation({
                        id: new Snowflake(BigInt(result.snowflake)),
                        ctx,
                        author: await User.getUser(result.author, ctx),
                        data: result.data,
                        category: await Category.getCategory(result.category, ctx),
                        name: result.name,
                        description: result.description,
                        length: result.length,
                        published: result.published
                    }));
                }

                resolve(col);
            });
        });
    }
}
/**
 * Holds animation data
 * @extends {BasicAnimation}
 */
class Animation extends BasicAnimation {
    client: UserClient;

    /**
     * Creates new animation data object
     * @param {object} data
     */
    constructor({
        id,
        ctx,
        author,
        data,
        category,
        name,
        description,
        length
    }: {
        id: Snowflake,
        ctx: RequestContextWithClient,
        author: User,
        data: string,
        category: Category,
        name: string,
        description: string,
        length: number
    }) {
        super({
            id,
            ctx,
            author,
            data,
            category,
            name,
            length,
            description
        });
        this.client = ctx.client;
    }

    /**
     * rates the animation
     * @param {Rating | number} rateResolvable
     * @return {Promise<Rating>}
     */
    async rate(rateResolvable: Rating | number): Promise<Rating> {
        var rate: Rating;
        if(rateResolvable instanceof Rating) rate = rateResolvable;
        else if(typeof rateResolvable === "number") {
            rate = new Rating({
                id: Snowflake.newSnowflake(),
                animation: this,
                ctx: this.ctx,
                stars: rateResolvable,
                user: this.client.user
            });
        } else throw new TypeError("rateResolvable must be Rating or number");

        if(rate.animation.id !== this.id) {
            throw new Error("rateResolvable must be for this animation");
        }

        const res = await prisma.rating.upsert({
            where: {
                authorId_animationId: {
                    authorId: rate.user.id.toBigInt(),
                    animationId: this.id.toBigInt()
                }
            },
            create: {
                snowflake: rate.id.toBigInt(),
                stars: rate.stars,
                animation: {
                    connect: {
                        snowflake: this.id.toBigInt()
                    }
                },
                author: {
                    connect: {
                        snowflake: rate.user.id.toBigInt()
                    }
                }
            },
            update: {
                stars: rate.stars
            }
        });
        rate.id = new Snowflake(res.snowflake);
        return rate;
    }

    /**
     * comments on the animation
     * @param {Comment | string} commentResolvable
     * @return {Promise<Comment>}
     */
    async comment(commentResolvable: Comment | string): Promise<Comment> {
        var comment: Comment;
        if(commentResolvable instanceof Comment) comment = commentResolvable;
        else if(typeof commentResolvable === "string") {
            comment = new Comment({
                id: Snowflake.newSnowflake(),
                animation: this,
                ctx: this.ctx,
                content: commentResolvable,
                user: this.client.user,
                state: CommentState.NORMAL
            });
        } else throw new Error("Invalid comment resolvable");
        await prisma.comment.create({
            data: {
                snowflake: comment.id.toBigInt(),
                animation: {
                    connect: {
                        snowflake: this.id.toBigInt()
                    }
                },
                author: {
                    connect: {
                        snowflake: this.client.user.id.toBigInt()
                    }
                },
                content: comment.content,
                state: comment.state
            }
        });
        return comment;
    }

    /**
     * Updates the animation data
     * @return {Promise<void>}
     */
    async update(): Promise<void> {
        if(this.author.id.getSnowflake() !== this.client.user.id.getSnowflake()) {
            throw new Error("Access rights");
        }

        await prisma.animation.update({
            where: {
                snowflake: this.id.toBigInt()
            },
            data: {
                data: typeof this.data === "string" ? this.data : JSON.stringify(this.data),
                name: this.name,
                description: this.description,
                category: {
                    connect: {
                        snowflake: this.category.id.toBigInt()
                    }
                },
                length: this.length,
                published: this.published,
                language: {
                    connect: {
                        id: this.language
                    }
                }
            }
        });
    }

    /**
     * Checks animation data
     * @param {any} data
     * @return {boolean}
     */
    static checkData(data: any) {
        if(
            typeof data !== "object" ||
            !Array.isArray(data.elements) ||
            data.elements.findIndex(
                (element: any) =>
                    typeof element.start !== "number" ||
                typeof element.end !== "number" ||
                typeof element.x !== "number" ||
                typeof element.y !== "number" ||
                typeof element.z !== "number" ||
                typeof element.media !== "object" ||
                typeof element.media.type !== "string" ||
                typeof element.media.width !== "number" ||
                typeof element.media.height !== "number" ||
                !Array.isArray(element.keyframes) ||
                element.keyframes.findIndex(
                    (keyframe: any) =>
                        typeof keyframe !== "object" ||
                    !["number", "undefined"].includes(typeof keyframe.t) ||
                    !["number", "undefined"].includes(typeof keyframe.x) ||
                    !["number", "undefined"].includes(typeof keyframe.y) ||
                    !["number", "undefined"].includes(typeof keyframe.w) ||
                    !["number", "undefined"].includes(typeof keyframe.h) ||
                    !["number", "undefined"].includes(typeof keyframe.r) ||
                    !["number", "undefined"].includes(typeof keyframe.rt) ||
                    !["string", "undefined"].includes(typeof keyframe.fill) ||
                    !["number", "undefined"].includes(typeof keyframe.o)
                ) !== -1
            ) !== -1
        ) {
            return false;
        }
        return true;
    }
}

export default Animation;