add poll type and table
This commit is contained in:
parent
bacb0a1ee0
commit
6d49a39273
10 changed files with 360 additions and 195 deletions
|
@ -1,3 +1,4 @@
|
|||
DROP TABLE IF EXISTS poll_vote;
|
||||
DROP MATERIALIZED VIEW IF EXISTS reaction_by_id;
|
||||
DROP MATERIALIZED VIEW IF EXISTS reaction_by_user_id;
|
||||
DROP INDEX IF EXISTS reaction_by_id;
|
||||
|
@ -15,6 +16,7 @@ DROP INDEX IF EXISTS note_by_reply_id;
|
|||
DROP INDEX IF EXISTS note_by_uri;
|
||||
DROP INDEX IF EXISTS note_by_url;
|
||||
DROP TABLE IF EXISTS note;
|
||||
DROP TYPE IF EXISTS poll;
|
||||
DROP TYPE IF EXISTS emoji;
|
||||
DROP TYPE IF EXISTS note_edit_history;
|
||||
DROP TYPE IF EXISTS drive_file;
|
||||
|
|
|
@ -29,6 +29,12 @@ CREATE TYPE IF NOT EXISTS emoji (
|
|||
"height" int,
|
||||
);
|
||||
|
||||
CREATE TYPE IF NOT EXISTS poll (
|
||||
"expiresAt" timestamp,
|
||||
"multiple" boolean,
|
||||
"choices" map<int, text>,
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note ( -- Store all posts
|
||||
"createdAtDate" date, -- For partitioning
|
||||
"createdAt" timestamp,
|
||||
|
@ -50,6 +56,7 @@ CREATE TABLE IF NOT EXISTS note ( -- Store all posts
|
|||
"emojis" set<text>,
|
||||
"tags" set<text>,
|
||||
"hasPoll" boolean,
|
||||
"poll" poll,
|
||||
"threadId" ascii,
|
||||
"channelId" ascii, -- Channel
|
||||
"userId" ascii, -- User
|
||||
|
@ -172,6 +179,7 @@ CREATE TABLE IF NOT EXISTS home_timeline (
|
|||
"emojis" set<text>,
|
||||
"tags" set<text>,
|
||||
"hasPoll" boolean,
|
||||
"poll" poll,
|
||||
"threadId" ascii,
|
||||
"channelId" ascii, -- Channel
|
||||
"userId" ascii, -- User
|
||||
|
@ -220,3 +228,11 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS reaction_by_id AS
|
|||
AND "reaction" IS NOT NULL
|
||||
AND "userId" IS NOT NULL
|
||||
PRIMARY KEY ("noteId", "reaction", "userId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS poll_vote (
|
||||
"noteId" ascii,
|
||||
"userId" ascii,
|
||||
"choice" set<int>,
|
||||
"createdAt" timestamp,
|
||||
PRIMARY KEY ("noteId", "userId")
|
||||
);
|
||||
|
|
|
@ -21,6 +21,7 @@ export const scyllaQueries = {
|
|||
"emojis",
|
||||
"tags",
|
||||
"hasPoll",
|
||||
"poll",
|
||||
"threadId",
|
||||
"channelId",
|
||||
"userId",
|
||||
|
@ -40,7 +41,7 @@ export const scyllaQueries = {
|
|||
"reactions",
|
||||
"noteEdit",
|
||||
"updatedAt"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
select: {
|
||||
byDate: `SELECT * FROM note WHERE "createdAtDate" = ?`,
|
||||
byUri: `SELECT * FROM note WHERE "uri" = ?`,
|
||||
|
@ -91,6 +92,7 @@ export const scyllaQueries = {
|
|||
"emojis",
|
||||
"tags",
|
||||
"hasPoll",
|
||||
"poll",
|
||||
"threadId",
|
||||
"channelId",
|
||||
"userId",
|
||||
|
@ -110,7 +112,7 @@ export const scyllaQueries = {
|
|||
"reactions",
|
||||
"noteEdit",
|
||||
"updatedAt"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
select: {
|
||||
byUserAndDate: `SELECT * FROM home_timeline WHERE "feedUserId" = ? AND "createdAtDate" = ?`,
|
||||
byId: `SELECT * FROM home_timeline WHERE "id" = ?`,
|
||||
|
@ -153,4 +155,8 @@ export const scyllaQueries = {
|
|||
},
|
||||
delete: `DELETE FROM reaction WHERE "noteId" = ? AND "userId" = ?`,
|
||||
},
|
||||
poll: {
|
||||
select: `SELECT * FROM poll_vote WHERE "noteId" = ?`,
|
||||
insert: `INSERT INTO poll_vote ("noteId", "userId", "choice", "createdAt") VALUES (?, ?, ?, ?)`,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -111,6 +111,28 @@ export interface ScyllaNoteEditHistory {
|
|||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ScyllaPoll {
|
||||
expiresAt: Date | null;
|
||||
multiple: boolean;
|
||||
choices: Map<number, string>,
|
||||
}
|
||||
|
||||
export interface ScyllaPollVote {
|
||||
noteId: string,
|
||||
userId: string,
|
||||
choice: Set<number>,
|
||||
createdAt: Date,
|
||||
}
|
||||
|
||||
export function parseScyllaPollVote(row: types.Row): ScyllaPollVote {
|
||||
return {
|
||||
noteId: row.get("noteId"),
|
||||
userId: row.get("userId"),
|
||||
choice: row.get("choice"),
|
||||
createdAt: row.get("createdAt"),
|
||||
}
|
||||
}
|
||||
|
||||
export type ScyllaNote = Note & {
|
||||
createdAtDate: Date;
|
||||
files: ScyllaDriveFile[];
|
||||
|
@ -121,6 +143,7 @@ export type ScyllaNote = Note & {
|
|||
renoteText: string | null;
|
||||
renoteCw: string | null;
|
||||
renoteFiles: ScyllaDriveFile[];
|
||||
poll: ScyllaPoll | null;
|
||||
};
|
||||
|
||||
export function parseScyllaNote(row: types.Row): ScyllaNote {
|
||||
|
@ -149,6 +172,7 @@ export function parseScyllaNote(row: types.Row): ScyllaNote {
|
|||
emojis: row.get("emojis") ?? [],
|
||||
tags: row.get("tags") ?? [],
|
||||
hasPoll: row.get("hasPoll") ?? false,
|
||||
poll: row.get("poll") ?? null,
|
||||
threadId: row.get("threadId") ?? null,
|
||||
channelId: row.get("channelId") ?? null,
|
||||
userId: row.get("userId"),
|
||||
|
|
|
@ -53,6 +53,7 @@ import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js";
|
|||
import { truncate } from "@/misc/truncate.js";
|
||||
import { type Size, getEmojiSize } from "@/misc/emoji-meta.js";
|
||||
import { fetchMeta } from "@/misc/fetch-meta.js";
|
||||
import { parseScyllaNote, prepared, scyllaClient } from "@/db/scylla.js";
|
||||
|
||||
const logger = apLogger;
|
||||
|
||||
|
@ -317,7 +318,37 @@ export async function createNote(
|
|||
}
|
||||
|
||||
// vote
|
||||
if (reply?.hasPoll) {
|
||||
if (reply?.hasPoll && note.name) {
|
||||
if (scyllaClient) {
|
||||
const result = await scyllaClient.execute(
|
||||
prepared.note.select.byId,
|
||||
[reply.id],
|
||||
{ prepare: true },
|
||||
);
|
||||
if (result.rowLength === 0) {
|
||||
throw new Error("reply target note not found");
|
||||
}
|
||||
const scyllaNote = parseScyllaNote(result.first());
|
||||
if (!scyllaNote.hasPoll || !scyllaNote.poll) {
|
||||
throw new Error("reply target does not have poll");
|
||||
}
|
||||
if (scyllaNote.poll.expiresAt && scyllaNote.poll.expiresAt < new Date()) {
|
||||
logger.warn(
|
||||
`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${scyllaNote.id}, choice=${note.name}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = Array.from(scyllaNote.poll.choices.entries()).find(
|
||||
([_, v]) => v === note.name,
|
||||
);
|
||||
if (entry) {
|
||||
await vote(actor, scyllaNote, entry[0]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const poll = await Polls.findOneByOrFail({ noteId: reply.id });
|
||||
|
||||
const tryCreateVote = async (
|
||||
|
@ -337,12 +368,10 @@ export async function createNote(
|
|||
return null;
|
||||
};
|
||||
|
||||
if (note.name) {
|
||||
return await tryCreateVote(
|
||||
note.name,
|
||||
poll.choices.findIndex((x) => x === note.name),
|
||||
);
|
||||
}
|
||||
return await tryCreateVote(
|
||||
note.name,
|
||||
poll.choices.findIndex((x) => x === note.name),
|
||||
);
|
||||
}
|
||||
|
||||
const emojis = await extractEmojis(note.tag || [], actor.host).catch((e) => {
|
||||
|
|
|
@ -73,6 +73,7 @@ import {
|
|||
parseScyllaNote,
|
||||
prepared,
|
||||
scyllaClient,
|
||||
ScyllaPoll,
|
||||
} from "@/db/scylla.js";
|
||||
|
||||
export const mutedWordsCache = new Cache<
|
||||
|
@ -703,26 +704,28 @@ async function incRenoteCount(renote: Note) {
|
|||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
const homeTimelines = await scyllaClient
|
||||
.execute(prepared.homeTimeline.select.byId, [renote.id], {
|
||||
prepare: true,
|
||||
})
|
||||
.then((result) => result.rows.map(parseHomeTimeline));
|
||||
// Do not issue BATCH because different home timelines involve different partitions
|
||||
for (const timeline of homeTimelines) {
|
||||
scyllaClient.execute(
|
||||
prepared.homeTimeline.update.renoteCount,
|
||||
[
|
||||
count + 1,
|
||||
score + 1,
|
||||
timeline.feedUserId,
|
||||
timeline.createdAtDate,
|
||||
timeline.createdAt,
|
||||
timeline.userId,
|
||||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
}
|
||||
scyllaClient.eachRow(
|
||||
prepared.homeTimeline.select.byId,
|
||||
[renote.id],
|
||||
{ prepare: true },
|
||||
(_, row) => {
|
||||
if (scyllaClient) {
|
||||
const timeline = parseHomeTimeline(row);
|
||||
scyllaClient.execute(
|
||||
prepared.homeTimeline.update.renoteCount,
|
||||
[
|
||||
count + 1,
|
||||
score + 1,
|
||||
timeline.feedUserId,
|
||||
timeline.createdAtDate,
|
||||
timeline.createdAt,
|
||||
timeline.userId,
|
||||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
Notes.createQueryBuilder()
|
||||
.update()
|
||||
|
@ -806,7 +809,7 @@ async function insertNote(
|
|||
);
|
||||
}
|
||||
|
||||
// 投稿を作成
|
||||
// Insert post to DB
|
||||
try {
|
||||
if (scyllaClient) {
|
||||
const fileMapper = (file: DriveFile) => ({
|
||||
|
@ -830,6 +833,24 @@ async function insertNote(
|
|||
)
|
||||
: null;
|
||||
|
||||
let poll: ScyllaPoll | null = null;
|
||||
|
||||
if (data.poll) {
|
||||
insert.hasPoll = true;
|
||||
let expiresAt: Date | null;
|
||||
if (!data.poll.expiresAt || isNaN(data.poll.expiresAt.getTime())) {
|
||||
expiresAt = null;
|
||||
} else {
|
||||
expiresAt = data.poll.expiresAt;
|
||||
}
|
||||
|
||||
poll = {
|
||||
expiresAt,
|
||||
choices: new Map(data.poll.choices.map((v, i) => [i, v] as [number, string])),
|
||||
multiple: data.poll.multiple,
|
||||
};
|
||||
}
|
||||
|
||||
const params = [
|
||||
insert.createdAt,
|
||||
insert.createdAt,
|
||||
|
@ -851,6 +872,7 @@ async function insertNote(
|
|||
insert.emojis,
|
||||
insert.tags,
|
||||
insert.hasPoll,
|
||||
poll,
|
||||
insert.threadId,
|
||||
insert.channelId,
|
||||
insert.userId,
|
||||
|
@ -895,38 +917,37 @@ async function insertNote(
|
|||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if (insert.hasPoll) {
|
||||
// Start transaction
|
||||
await db.transaction(async (transactionalEntityManager) => {
|
||||
if (!data.poll) throw new Error("Empty poll data");
|
||||
} else {
|
||||
if (insert.hasPoll) {
|
||||
// Start transaction
|
||||
await db.transaction(async (transactionalEntityManager) => {
|
||||
if (!data.poll) throw new Error("Empty poll data");
|
||||
|
||||
if (!scyllaClient) {
|
||||
await transactionalEntityManager.insert(Note, insert);
|
||||
}
|
||||
|
||||
let expiresAt: Date | null;
|
||||
if (!data.poll.expiresAt || isNaN(data.poll.expiresAt.getTime())) {
|
||||
expiresAt = null;
|
||||
} else {
|
||||
expiresAt = data.poll.expiresAt;
|
||||
}
|
||||
let expiresAt: Date | null;
|
||||
if (!data.poll.expiresAt || isNaN(data.poll.expiresAt.getTime())) {
|
||||
expiresAt = null;
|
||||
} else {
|
||||
expiresAt = data.poll.expiresAt;
|
||||
}
|
||||
|
||||
const poll = new Poll({
|
||||
noteId: insert.id,
|
||||
choices: data.poll.choices,
|
||||
expiresAt,
|
||||
multiple: data.poll.multiple,
|
||||
votes: new Array(data.poll.choices.length).fill(0),
|
||||
noteVisibility: insert.visibility,
|
||||
userId: user.id,
|
||||
userHost: user.host,
|
||||
const poll = new Poll({
|
||||
noteId: insert.id,
|
||||
choices: data.poll.choices,
|
||||
expiresAt,
|
||||
multiple: data.poll.multiple,
|
||||
votes: new Array(data.poll.choices.length).fill(0),
|
||||
noteVisibility: insert.visibility,
|
||||
userId: user.id,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(Poll, poll);
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(Poll, poll);
|
||||
});
|
||||
} else if (!scyllaClient) {
|
||||
await Notes.insert(insert);
|
||||
} else {
|
||||
await Notes.insert(insert);
|
||||
}
|
||||
}
|
||||
|
||||
if (scyllaClient) {
|
||||
|
@ -1074,25 +1095,27 @@ async function saveReply(reply: Note) {
|
|||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
const homeTimelines = await scyllaClient
|
||||
.execute(prepared.homeTimeline.select.byId, [reply.id], {
|
||||
prepare: true,
|
||||
})
|
||||
.then((result) => result.rows.map(parseHomeTimeline));
|
||||
// Do not issue BATCH because different home timelines involve different partitions
|
||||
for (const timeline of homeTimelines) {
|
||||
scyllaClient.execute(
|
||||
prepared.homeTimeline.update.repliesCount,
|
||||
[
|
||||
count + 1,
|
||||
timeline.feedUserId,
|
||||
timeline.createdAtDate,
|
||||
timeline.createdAt,
|
||||
timeline.userId,
|
||||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
}
|
||||
scyllaClient.eachRow(
|
||||
prepared.homeTimeline.select.byId,
|
||||
[reply.id],
|
||||
{ prepare: true },
|
||||
(_, row) => {
|
||||
if (scyllaClient) {
|
||||
const timeline = parseHomeTimeline(row);
|
||||
scyllaClient.execute(
|
||||
prepared.homeTimeline.update.repliesCount,
|
||||
[
|
||||
count + 1,
|
||||
timeline.feedUserId,
|
||||
timeline.createdAtDate,
|
||||
timeline.createdAt,
|
||||
timeline.userId,
|
||||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await Notes.increment({ id: reply.id }, "repliesCount", 1);
|
||||
}
|
||||
|
|
|
@ -70,26 +70,30 @@ export default async function (
|
|||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
const homeTimelines = await scyllaClient
|
||||
.execute(prepared.homeTimeline.select.byId, [renote.id], {
|
||||
scyllaClient.eachRow(
|
||||
prepared.homeTimeline.select.byId,
|
||||
[renote.id],
|
||||
{
|
||||
prepare: true,
|
||||
})
|
||||
.then((result) => result.rows.map(parseHomeTimeline));
|
||||
// Do not issue BATCH because different home timelines involve different partitions
|
||||
for (const timeline of homeTimelines) {
|
||||
scyllaClient.execute(
|
||||
prepared.homeTimeline.update.renoteCount,
|
||||
[
|
||||
Math.max(count - 1, 0),
|
||||
Math.max(score - 1, 0),
|
||||
timeline.feedUserId,
|
||||
timeline.createdAtDate,
|
||||
timeline.createdAt,
|
||||
timeline.userId,
|
||||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
(_, row) => {
|
||||
if (scyllaClient) {
|
||||
const timeline = parseHomeTimeline(row);
|
||||
scyllaClient.execute(
|
||||
prepared.homeTimeline.update.renoteCount,
|
||||
[
|
||||
Math.max(count - 1, 0),
|
||||
Math.max(score - 1, 0),
|
||||
timeline.feedUserId,
|
||||
timeline.createdAtDate,
|
||||
timeline.createdAt,
|
||||
timeline.userId,
|
||||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Notes.decrement({ id: note.renoteId }, "renoteCount", 1);
|
||||
|
@ -119,25 +123,29 @@ export default async function (
|
|||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
const homeTimelines = await scyllaClient
|
||||
.execute(prepared.homeTimeline.select.byId, [reply.id], {
|
||||
scyllaClient.eachRow(
|
||||
prepared.homeTimeline.select.byId,
|
||||
[reply.id],
|
||||
{
|
||||
prepare: true,
|
||||
})
|
||||
.then((result) => result.rows.map(parseHomeTimeline));
|
||||
// Do not issue BATCH because different home timelines involve different partitions
|
||||
for (const timeline of homeTimelines) {
|
||||
scyllaClient.execute(
|
||||
prepared.homeTimeline.update.repliesCount,
|
||||
[
|
||||
Math.max(count - 1, 0),
|
||||
timeline.feedUserId,
|
||||
timeline.createdAtDate,
|
||||
timeline.createdAt,
|
||||
timeline.userId,
|
||||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
(_, row) => {
|
||||
if (scyllaClient) {
|
||||
const timeline = parseHomeTimeline(row);
|
||||
scyllaClient.execute(
|
||||
prepared.homeTimeline.update.repliesCount,
|
||||
[
|
||||
Math.max(count - 1, 0),
|
||||
timeline.feedUserId,
|
||||
timeline.createdAtDate,
|
||||
timeline.createdAt,
|
||||
timeline.userId,
|
||||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await Notes.decrement({ id: note.replyId }, "repliesCount", 1);
|
||||
|
|
|
@ -1,64 +1,113 @@
|
|||
import { publishNoteStream } from "@/services/stream.js";
|
||||
import type { CacheableUser } from "@/models/entities/user.js";
|
||||
import { User } from "@/models/entities/user.js";
|
||||
import type { Note } from "@/models/entities/note.js";
|
||||
import { PollVotes, NoteWatchings, Polls, Blockings } from "@/models/index.js";
|
||||
import { Not } from "typeorm";
|
||||
import { genId } from "@/misc/gen-id.js";
|
||||
import { createNotification } from "../../create-notification.js";
|
||||
import {
|
||||
type ScyllaNote,
|
||||
type ScyllaPollVote,
|
||||
scyllaClient,
|
||||
prepared,
|
||||
parseScyllaPollVote,
|
||||
} from "@/db/scylla.js";
|
||||
import { UserBlockingCache } from "@/misc/cache.js";
|
||||
|
||||
export default async function (
|
||||
user: CacheableUser,
|
||||
note: Note,
|
||||
note: Note | ScyllaNote,
|
||||
choice: number,
|
||||
) {
|
||||
const poll = await Polls.findOneBy({ noteId: note.id });
|
||||
|
||||
if (poll == null) throw new Error("poll not found");
|
||||
|
||||
// Check whether is valid choice
|
||||
if (poll.choices[choice] == null) throw new Error("invalid choice param");
|
||||
|
||||
// Check blocking
|
||||
if (note.userId !== user.id) {
|
||||
const block = await Blockings.findOneBy({
|
||||
blockerId: note.userId,
|
||||
blockeeId: user.id,
|
||||
});
|
||||
if (block) {
|
||||
throw new Error("blocked");
|
||||
if (scyllaClient) {
|
||||
const scyllaNote = note as ScyllaNote;
|
||||
if (!scyllaNote.hasPoll || !scyllaNote.poll) {
|
||||
throw new Error("poll not found");
|
||||
}
|
||||
}
|
||||
|
||||
// if already voted
|
||||
const exist = await PollVotes.findBy({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
if (!Array.from(scyllaNote.poll.choices.keys()).includes(choice)) {
|
||||
throw new Error("invalid choice param");
|
||||
}
|
||||
|
||||
if (poll.multiple) {
|
||||
if (exist.some((x) => x.choice === choice)) {
|
||||
if (scyllaNote.userId !== user.id) {
|
||||
const isBlocking = await UserBlockingCache.init(scyllaNote.userId).then(
|
||||
(cache) => cache.has(user.id),
|
||||
);
|
||||
if (isBlocking) {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
}
|
||||
|
||||
let newChoice: ScyllaPollVote["choice"] = new Set();
|
||||
const result = await scyllaClient.execute(
|
||||
`${prepared.poll.select} AND "userId" = ?`,
|
||||
[scyllaNote.id, user.id],
|
||||
{ prepare: true },
|
||||
);
|
||||
if (result.rowLength > 0) {
|
||||
const vote = parseScyllaPollVote(result.first());
|
||||
if (scyllaNote.poll.multiple && !vote.choice.has(choice)) {
|
||||
newChoice = vote.choice.add(choice);
|
||||
} else {
|
||||
throw new Error("already voted");
|
||||
}
|
||||
} else {
|
||||
newChoice.add(choice);
|
||||
}
|
||||
|
||||
await scyllaClient.execute(
|
||||
prepared.poll.insert,
|
||||
[scyllaNote.id, user.id, newChoice, new Date()],
|
||||
{ prepare: true },
|
||||
);
|
||||
} else {
|
||||
const poll = await Polls.findOneBy({ noteId: note.id });
|
||||
if (!poll) throw new Error("poll not found");
|
||||
|
||||
// Check whether is valid choice
|
||||
if (poll.choices[choice] == null) throw new Error("invalid choice param");
|
||||
|
||||
// Check blocking
|
||||
if (note.userId !== user.id) {
|
||||
const block = await Blockings.findOneBy({
|
||||
blockerId: note.userId,
|
||||
blockeeId: user.id,
|
||||
});
|
||||
if (block) {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
}
|
||||
|
||||
// if already voted
|
||||
const exist = await PollVotes.findBy({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (poll.multiple) {
|
||||
if (exist.some((x) => x.choice === choice)) {
|
||||
throw new Error("already voted");
|
||||
}
|
||||
} else if (exist.length !== 0) {
|
||||
throw new Error("already voted");
|
||||
}
|
||||
} else if (exist.length !== 0) {
|
||||
throw new Error("already voted");
|
||||
|
||||
// Create vote
|
||||
await PollVotes.insert({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
choice: choice,
|
||||
});
|
||||
|
||||
// Increment votes count
|
||||
const index = choice + 1; // In SQL, array index is 1 based
|
||||
await Polls.query(
|
||||
`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create vote
|
||||
await PollVotes.insert({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
choice: choice,
|
||||
});
|
||||
|
||||
// Increment votes count
|
||||
const index = choice + 1; // In SQL, array index is 1 based
|
||||
await Polls.query(
|
||||
`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`,
|
||||
);
|
||||
|
||||
publishNoteStream(note.id, "pollVoted", {
|
||||
choice: choice,
|
||||
userId: user.id,
|
||||
|
|
|
@ -123,27 +123,31 @@ export default async (
|
|||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
const homeTimelines = await scyllaClient
|
||||
.execute(prepared.homeTimeline.select.byId, [note.id], {
|
||||
scyllaClient.eachRow(
|
||||
prepared.homeTimeline.select.byId,
|
||||
[note.id],
|
||||
{
|
||||
prepare: true,
|
||||
})
|
||||
.then((result) => result.rows.map(parseHomeTimeline));
|
||||
// Do not issue BATCH because different home timelines involve different partitions
|
||||
for (const timeline of homeTimelines) {
|
||||
scyllaClient.execute(
|
||||
prepared.homeTimeline.update.reactions,
|
||||
[
|
||||
note.emojis.concat(emojiName),
|
||||
note.reactions,
|
||||
score + 1,
|
||||
timeline.feedUserId,
|
||||
timeline.createdAtDate,
|
||||
timeline.createdAt,
|
||||
timeline.userId,
|
||||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
(_, row) => {
|
||||
if (scyllaClient) {
|
||||
const timeline = parseHomeTimeline(row);
|
||||
scyllaClient.execute(
|
||||
prepared.homeTimeline.update.reactions,
|
||||
[
|
||||
note.emojis.concat(emojiName),
|
||||
note.reactions,
|
||||
score + 1,
|
||||
timeline.feedUserId,
|
||||
timeline.createdAtDate,
|
||||
timeline.createdAt,
|
||||
timeline.userId,
|
||||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const sql = `jsonb_set("reactions", '{${_reaction}}', (COALESCE("reactions"->>'${_reaction}', '0')::int + 1)::text::jsonb)`;
|
||||
await Notes.createQueryBuilder()
|
||||
|
|
|
@ -79,27 +79,31 @@ export default async (
|
|||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
const homeTimelines = await scyllaClient
|
||||
.execute(prepared.homeTimeline.select.byId, [note.id], {
|
||||
scyllaClient.eachRow(
|
||||
prepared.homeTimeline.select.byId,
|
||||
[note.id],
|
||||
{
|
||||
prepare: true,
|
||||
})
|
||||
.then((result) => result.rows.map(parseHomeTimeline));
|
||||
// Do not issue BATCH because different home timelines involve different partitions
|
||||
for (const timeline of homeTimelines) {
|
||||
scyllaClient.execute(
|
||||
prepared.homeTimeline.update.reactions,
|
||||
[
|
||||
note.emojis.concat(emojiName),
|
||||
note.reactions,
|
||||
Math.max(score - 1, 0),
|
||||
timeline.feedUserId,
|
||||
timeline.createdAtDate,
|
||||
timeline.createdAt,
|
||||
timeline.userId,
|
||||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
(_, row) => {
|
||||
if (scyllaClient) {
|
||||
const timeline = parseHomeTimeline(row);
|
||||
scyllaClient.execute(
|
||||
prepared.homeTimeline.update.reactions,
|
||||
[
|
||||
note.emojis.concat(emojiName),
|
||||
note.reactions,
|
||||
Math.max(score - 1, 0),
|
||||
timeline.feedUserId,
|
||||
timeline.createdAtDate,
|
||||
timeline.createdAt,
|
||||
timeline.userId,
|
||||
],
|
||||
{ prepare: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const sql = `jsonb_set("reactions", '{${reaction.reaction}}', (COALESCE("reactions"->>'${reaction.reaction}', '0')::int - 1)::text::jsonb)`;
|
||||
await Notes.createQueryBuilder()
|
||||
|
|
Loading…
Reference in a new issue