| 
 | 1 | +import { AppDataSource } from "../database/data-source";  | 
 | 2 | +import { Poll } from "../database/entities/Poll";  | 
 | 3 | +import { User } from "../database/entities/User";  | 
 | 4 | +import {  | 
 | 5 | +    type PointVoteData,  | 
 | 6 | +    type RankVoteData,  | 
 | 7 | +    Vote,  | 
 | 8 | +    type VoteDataByMode,  | 
 | 9 | +} from "../database/entities/Vote";  | 
 | 10 | + | 
 | 11 | +export function isValidRankVote(  | 
 | 12 | +    data: unknown,  | 
 | 13 | +    pollOptions: string[],  | 
 | 14 | +): data is RankVoteData {  | 
 | 15 | +    if (!Array.isArray(data) || data.length !== pollOptions.length)  | 
 | 16 | +        return false;  | 
 | 17 | + | 
 | 18 | +    const optionSet = new Set(data.map((d) => d.option));  | 
 | 19 | +    const pointSet = new Set(data.map((d) => d.points));  | 
 | 20 | + | 
 | 21 | +    return (  | 
 | 22 | +        optionSet.size === pollOptions.length &&  | 
 | 23 | +        pointSet.size === pollOptions.length &&  | 
 | 24 | +        data.every(  | 
 | 25 | +            (d) =>  | 
 | 26 | +                typeof d.option === "string" &&  | 
 | 27 | +                typeof d.points === "number" &&  | 
 | 28 | +                pollOptions.includes(d.option) &&  | 
 | 29 | +                d.points >= 1 &&  | 
 | 30 | +                d.points <= pollOptions.length,  | 
 | 31 | +        )  | 
 | 32 | +    );  | 
 | 33 | +}  | 
 | 34 | + | 
 | 35 | +function isValidPointVote(  | 
 | 36 | +    data: unknown,  | 
 | 37 | +    pollOptions: string[],  | 
 | 38 | +): data is PointVoteData {  | 
 | 39 | +    return (  | 
 | 40 | +        Array.isArray(data) &&  | 
 | 41 | +        data.length > 0 &&  | 
 | 42 | +        new Set(data.map((d) => d.option)).size === data.length &&  | 
 | 43 | +        data.every(  | 
 | 44 | +            (d) =>  | 
 | 45 | +                typeof d.option === "string" &&  | 
 | 46 | +                typeof d.points === "number" &&  | 
 | 47 | +                pollOptions.includes(d.option),  | 
 | 48 | +        )  | 
 | 49 | +    );  | 
 | 50 | +}  | 
 | 51 | + | 
 | 52 | +export class PollService {  | 
 | 53 | +    private pollRepository = AppDataSource.getRepository(Poll);  | 
 | 54 | +    private voteRepository = AppDataSource.getRepository(Vote);  | 
 | 55 | +    private userRepository = AppDataSource.getRepository(User);  | 
 | 56 | + | 
 | 57 | +    // Create a new poll  | 
 | 58 | +    async createPoll(  | 
 | 59 | +        title: string,  | 
 | 60 | +        mode: "normal" | "point" | "rank",  | 
 | 61 | +        visibility: "public" | "private",  | 
 | 62 | +        options: string[],  | 
 | 63 | +        deadline?: Date,  | 
 | 64 | +    ): Promise<Poll> {  | 
 | 65 | +        if (options.length < 2) {  | 
 | 66 | +            throw new Error("At least two options are required");  | 
 | 67 | +        }  | 
 | 68 | + | 
 | 69 | +        const poll = this.pollRepository.create({  | 
 | 70 | +            title,  | 
 | 71 | +            mode,  | 
 | 72 | +            visibility,  | 
 | 73 | +            options,  | 
 | 74 | +            deadline: deadline ?? null,  | 
 | 75 | +        });  | 
 | 76 | + | 
 | 77 | +        return await this.pollRepository.save(poll);  | 
 | 78 | +    }  | 
 | 79 | + | 
 | 80 | +    // Get a poll by ID  | 
 | 81 | +    async getPollById(pollId: string): Promise<Poll | null> {  | 
 | 82 | +        return await this.pollRepository.findOne({  | 
 | 83 | +            where: { id: pollId },  | 
 | 84 | +            relations: ["votes"],  | 
 | 85 | +        });  | 
 | 86 | +    }  | 
 | 87 | + | 
 | 88 | +    // Get all polls (optionally by visibility)  | 
 | 89 | +    async getPolls(visibility?: "public" | "private"): Promise<Poll[]> {  | 
 | 90 | +        return this.pollRepository.find({  | 
 | 91 | +            where: visibility ? { visibility } : {},  | 
 | 92 | +            relations: ["votes"],  | 
 | 93 | +        });  | 
 | 94 | +    }  | 
 | 95 | + | 
 | 96 | +    // Cast a vote  | 
 | 97 | +    async castVote(  | 
 | 98 | +        pollId: string,  | 
 | 99 | +        userId: string,  | 
 | 100 | +        data: VoteDataByMode, // should be validated before calling  | 
 | 101 | +    ): Promise<Vote> {  | 
 | 102 | +        const poll = await this.getPollById(pollId);  | 
 | 103 | +        if (!poll) throw new Error("Poll not found");  | 
 | 104 | + | 
 | 105 | +        const now = new Date();  | 
 | 106 | +        if (poll.deadline && poll.deadline < now) {  | 
 | 107 | +            throw new Error("Poll has expired");  | 
 | 108 | +        }  | 
 | 109 | + | 
 | 110 | +        const user = await this.userRepository.findOneBy({ id: userId });  | 
 | 111 | +        if (!user) throw new Error("User not found");  | 
 | 112 | + | 
 | 113 | +        // Check if user already voted  | 
 | 114 | +        const existing = await this.voteRepository.findOneBy({  | 
 | 115 | +            pollId,  | 
 | 116 | +            voterId: userId,  | 
 | 117 | +        });  | 
 | 118 | +        if (existing) throw new Error("User has already voted");  | 
 | 119 | + | 
 | 120 | +        // Validate data based on poll mode  | 
 | 121 | +        switch (poll.mode) {  | 
 | 122 | +            case "normal":  | 
 | 123 | +                if (  | 
 | 124 | +                    !Array.isArray(data) ||  | 
 | 125 | +                    data.length === 0 ||  | 
 | 126 | +                    data.some(  | 
 | 127 | +                        (d) =>  | 
 | 128 | +                            typeof d !== "string" || !poll.options.includes(d),  | 
 | 129 | +                    )  | 
 | 130 | +                ) {  | 
 | 131 | +                    throw new Error("Invalid vote for normal mode");  | 
 | 132 | +                }  | 
 | 133 | +                break;  | 
 | 134 | +            case "point":  | 
 | 135 | +                if (  | 
 | 136 | +                    !Array.isArray(data) ||  | 
 | 137 | +                    data.length === 0 ||  | 
 | 138 | +                    data.some(  | 
 | 139 | +                        (d) =>  | 
 | 140 | +                            typeof d.option !== "string" ||  | 
 | 141 | +                            typeof d.points !== "number" ||  | 
 | 142 | +                            !poll.options.includes(d.option),  | 
 | 143 | +                    )  | 
 | 144 | +                ) {  | 
 | 145 | +                    throw new Error("Invalid vote for point mode");  | 
 | 146 | +                }  | 
 | 147 | +                break;  | 
 | 148 | +            case "rank":  | 
 | 149 | +                if (  | 
 | 150 | +                    !Array.isArray(data) ||  | 
 | 151 | +                    data.length !== poll.options.length ||  | 
 | 152 | +                    data.some((d) => typeof d !== "string") ||  | 
 | 153 | +                    new Set(data).size !== data.length ||  | 
 | 154 | +                    data.some((option) => !poll.options.includes(option))  | 
 | 155 | +                ) {  | 
 | 156 | +                    throw new Error("Invalid vote for rank mode");  | 
 | 157 | +                }  | 
 | 158 | +                break;  | 
 | 159 | +        }  | 
 | 160 | + | 
 | 161 | +        const vote = this.voteRepository.create({  | 
 | 162 | +            poll,  | 
 | 163 | +            voterId: user.id,  | 
 | 164 | +            data,  | 
 | 165 | +        });  | 
 | 166 | + | 
 | 167 | +        return await this.voteRepository.save(vote);  | 
 | 168 | +    }  | 
 | 169 | + | 
 | 170 | +    // Get votes for a poll  | 
 | 171 | +    async getVotes(pollId: string): Promise<Vote[]> {  | 
 | 172 | +        return await this.voteRepository.find({  | 
 | 173 | +            where: { pollId },  | 
 | 174 | +        });  | 
 | 175 | +    }  | 
 | 176 | + | 
 | 177 | +    // Get vote count  | 
 | 178 | +    async getVoteCount(pollId: string): Promise<number> {  | 
 | 179 | +        return await this.voteRepository.count({  | 
 | 180 | +            where: { pollId },  | 
 | 181 | +        });  | 
 | 182 | +    }  | 
 | 183 | + | 
 | 184 | +    // Optional: remove vote (e.g., allow user to change vote)  | 
 | 185 | +    async removeVote(pollId: string, userId: string): Promise<void> {  | 
 | 186 | +        await this.voteRepository.delete({  | 
 | 187 | +            pollId,  | 
 | 188 | +            voterId: userId,  | 
 | 189 | +        });  | 
 | 190 | +    }  | 
 | 191 | + | 
 | 192 | +    // Optional: close poll early  | 
 | 193 | +    async closePoll(pollId: string): Promise<Poll> {  | 
 | 194 | +        const poll = await this.getPollById(pollId);  | 
 | 195 | +        if (!poll) throw new Error("Poll not found");  | 
 | 196 | + | 
 | 197 | +        poll.deadline = new Date();  | 
 | 198 | +        return await this.pollRepository.save(poll);  | 
 | 199 | +    }  | 
 | 200 | +}  | 
0 commit comments