feat(discussions): questions on published studysets, answered by learners
A learner asks about a published studyset, a topic in it, or one card, note or step of a release, and others answer, as on Stack Exchange (ADR 0013): questions and answers in the app's editor without images, sounds or videos, comments of up to 600 characters under the question and each answer. There are no votes: the asker accepts an answer, which comes first, then the oldest; the author's posts are marked. The instance reads from git's file list which topics a question's target sits in, so a topic lists what is asked within it and below it. Posts live in two server-only tables, written through endpoints, so the synced tables stay as they are and v0.17.0 keeps syncing. Everyone signed in reads; writing needs an identity and a public profile; the studyset's author hides posts and blocks people with feedback's blocks; a withdrawn studyset takes no new questions; anyone writes at most 30 posts an hour. A post with others' replies under it is emptied when deleted, else deleted; deletePerson deletes what a person wrote the same way, and a published studyset's deletion takes its discussions with it. The migration adds Serverpod's gen_random_uuid_v7(), discussion_post and discussion_seen with their indexes and cascading foreign keys, and changes nothing else. In the app, discussions open from a copy's studyset or topic menu, a studied card's "Ask about this", Explore's summary and the Publish sheet; the Profile tab's inbox lists those the person follows, and its count includes what is new in them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
25237b8011
commit
5efa552489
33 changed files with 28214 additions and 27 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/auth_config.dart';
|
||||
import 'package:halkyon_learn_server/src/discussion/discussion_delete.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
|
||||
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
|
||||
|
|
@ -61,7 +62,10 @@ Future<void> deletePerson(Session session, UuidValue authUserId) async {
|
|||
await forgetAvatar(session, hash, transaction: transaction);
|
||||
}
|
||||
}
|
||||
// Their releases go with them.
|
||||
// What they wrote in discussions: for good, or kept without them for
|
||||
// others' replies under it (ADR 0013); what they read.
|
||||
await deleteDiscussionsOf(session, authUserId, transaction: transaction);
|
||||
// Their releases go with them, and the discussions on them.
|
||||
// DELETE FROM "published_studyset" WHERE "authUserId" = $1
|
||||
final published = await PublishedStudyset.db.deleteWhere(
|
||||
session,
|
||||
|
|
|
|||
132
halkyon_learn_server/lib/src/discussion/discussion_delete.dart
Normal file
132
halkyon_learn_server/lib/src/discussion/discussion_delete.dart
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
// Deleting what people wrote in discussions (ADR 0013): a post nobody else
|
||||
// replied under goes for good; one with others' replies under it stays for
|
||||
// them, its title, text and writer gone.
|
||||
|
||||
/// Records that [person] read the discussion [questionId] [at]: when they
|
||||
/// ask it, write in it or say they read it.
|
||||
Future<void> markRead(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
UuidValue questionId,
|
||||
DateTime at,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
// SELECT … FROM "discussion_seen"
|
||||
// WHERE "authUserId" = $1 AND "questionId" = $2
|
||||
final row = await DiscussionSeen.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person) & t.questionId.equals(questionId),
|
||||
transaction: transaction,
|
||||
);
|
||||
if (row == null) {
|
||||
// INSERT INTO "discussion_seen" …
|
||||
await DiscussionSeen.db.insertRow(
|
||||
session,
|
||||
DiscussionSeen(authUserId: person, questionId: questionId, seenAt: at),
|
||||
transaction: transaction,
|
||||
);
|
||||
} else {
|
||||
// UPDATE "discussion_seen" SET "seenAt" = $2 WHERE "id" = $1
|
||||
await DiscussionSeen.db.updateRow(
|
||||
session,
|
||||
row.copyWith(seenAt: at),
|
||||
columns: (t) => [t.seenAt],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes [post], as its writer asked or with their account: for good when
|
||||
/// nobody else replied under it (with the writer's own replies), else its
|
||||
/// title, text and writer go and it stays for the replies. An answer deleted
|
||||
/// is accepted no more.
|
||||
Future<void> deleteDiscussionPost(
|
||||
Session session,
|
||||
DiscussionPost post, {
|
||||
required Transaction transaction,
|
||||
}) async {
|
||||
final writer = post.authUserId;
|
||||
// SELECT count(*) FROM "discussion_post" WHERE ("questionId" = $1, for a
|
||||
// question; "parentId" = $1, for an answer)
|
||||
// AND ("authUserId" <> $2 OR "authUserId" IS NULL)
|
||||
final othersReplies = post.kind == 'comment'
|
||||
? 0
|
||||
: await DiscussionPost.db.count(
|
||||
session,
|
||||
where: (t) =>
|
||||
(post.kind == 'question'
|
||||
? t.questionId.equals(post.id)
|
||||
: t.parentId.equals(post.id)) &
|
||||
(t.authUserId.notEquals(writer) | t.authUserId.equals(null)),
|
||||
transaction: transaction,
|
||||
);
|
||||
if (othersReplies > 0) {
|
||||
// UPDATE "discussion_post" SET "authUserId" = NULL, "title" = '' (a
|
||||
// question's), "body" = '', "editedAt" = NULL, "deletedAt" = $2
|
||||
// WHERE "id" = $1
|
||||
await DiscussionPost.db.updateWhere(
|
||||
session,
|
||||
columnValues: (t) => [
|
||||
t.authUserId(null),
|
||||
if (post.kind == 'question') t.title(''),
|
||||
t.body(''),
|
||||
t.editedAt(null),
|
||||
t.deletedAt(clock.now().toUtc()),
|
||||
],
|
||||
where: (t) => t.id.equals(post.id),
|
||||
transaction: transaction,
|
||||
);
|
||||
} else {
|
||||
// DELETE FROM "discussion_post" WHERE "id" = $1, and what is under it.
|
||||
await DiscussionPost.db.deleteRow(session, post, transaction: transaction);
|
||||
}
|
||||
if (post.kind == 'answer') {
|
||||
// UPDATE "discussion_post" SET "acceptedAnswerId" = NULL,
|
||||
// "acceptedAt" = NULL WHERE "acceptedAnswerId" = $1
|
||||
await DiscussionPost.db.updateWhere(
|
||||
session,
|
||||
columnValues: (t) => [t.acceptedAnswerId(null), t.acceptedAt(null)],
|
||||
where: (t) => t.acceptedAnswerId.equals(post.id),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes what [person] wrote in discussions, with their account: their
|
||||
/// comments, answers and questions in that order, each for good or, with
|
||||
/// others' replies under it, kept without them; and what they read.
|
||||
Future<void> deleteDiscussionsOf(
|
||||
Session session,
|
||||
UuidValue person, {
|
||||
required Transaction transaction,
|
||||
}) async {
|
||||
for (final kind in const ['comment', 'answer', 'question']) {
|
||||
// SELECT … FROM "discussion_post" WHERE "authUserId" = $1 AND "kind" = $2
|
||||
for (final post in await DiscussionPost.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person) & t.kind.equals(kind),
|
||||
transaction: transaction,
|
||||
)) {
|
||||
// Gone with a post deleted before it.
|
||||
if (await DiscussionPost.db.findById(
|
||||
session,
|
||||
post.id!,
|
||||
transaction: transaction,
|
||||
) ==
|
||||
null) {
|
||||
continue;
|
||||
}
|
||||
await deleteDiscussionPost(session, post, transaction: transaction);
|
||||
}
|
||||
}
|
||||
// DELETE FROM "discussion_seen" WHERE "authUserId" = $1
|
||||
await DiscussionSeen.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
569
halkyon_learn_server/lib/src/discussion/discussion_endpoint.dart
Normal file
569
halkyon_learn_server/lib/src/discussion/discussion_endpoint.dart
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/scopes.dart';
|
||||
import 'package:halkyon_learn_server/src/discussion/discussion_delete.dart';
|
||||
import 'package:halkyon_learn_server/src/discussion/discussion_read.dart';
|
||||
import 'package:halkyon_learn_server/src/discussion/discussion_rules.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// A published studyset's discussions (ADR 0013), for everyone signed in to
|
||||
/// read: questions about it, a topic in it or one of its items, with their
|
||||
/// answers and comments; but for what its author hid, and what people the
|
||||
/// author blocked wrote.
|
||||
class DiscussionsEndpoint extends Endpoint {
|
||||
@override
|
||||
bool get requireLogin => true;
|
||||
|
||||
/// The questions on the published studyset [publicId], those last active
|
||||
/// first, [discussionPage] to a [page] (the first without): within [topic] (a studyset of
|
||||
/// the release, by its id: questions about it and what is inside it),
|
||||
/// about [item] alone, or with [query] in their titles. A page may hold
|
||||
/// fewer than it could, when the caller may not see some. Throws
|
||||
/// [NotFoundException] for a studyset never published.
|
||||
Future<List<DiscussionSummary>> list(
|
||||
Session session,
|
||||
UuidValue publicId, {
|
||||
String? topic,
|
||||
DiscussionTarget? item,
|
||||
String? query,
|
||||
int? page,
|
||||
}) async {
|
||||
final viewer = session.authenticated!.authUserId;
|
||||
final published = await publishedStudyset(session, publicId);
|
||||
String escaped(String text) =>
|
||||
text.replaceAllMapped(RegExp(r'[\\%_]'), (m) => '\\${m[0]}');
|
||||
// SELECT … FROM "discussion_post" WHERE "publishedStudysetId" = $1
|
||||
// AND "kind" = 'question' [AND "topics" LIKE '%|<topic>|%']
|
||||
// [AND "targetKind" = $2 AND "targetItem" = $3]
|
||||
// [AND "title" ILIKE '%<query>%']
|
||||
// ORDER BY "activeAt" DESC LIMIT 30 OFFSET $4
|
||||
final questions = await DiscussionPost.db.find(
|
||||
session,
|
||||
where: (t) {
|
||||
var where =
|
||||
t.publishedStudysetId.equals(published.id) &
|
||||
t.kind.equals('question');
|
||||
if (topic != null && topic.isNotEmpty) {
|
||||
where &= t.topics.like('%|${escaped(topic)}|%');
|
||||
}
|
||||
if (item != null) {
|
||||
where &=
|
||||
t.targetKind.equals(item.kind) & t.targetItem.equals(item.item);
|
||||
}
|
||||
if (query != null && query.trim().isNotEmpty) {
|
||||
where &= t.title.ilike('%${escaped(query.trim())}%');
|
||||
}
|
||||
return where;
|
||||
},
|
||||
orderByList: (t) => [t.activeAt.desc()],
|
||||
limit: discussionPage,
|
||||
offset: (page ?? 0).clamp(0, 1000) * discussionPage,
|
||||
);
|
||||
final reading = await DiscussionReading.of(
|
||||
session,
|
||||
viewer: viewer,
|
||||
questions: questions,
|
||||
);
|
||||
return [
|
||||
for (final question in questions)
|
||||
if (reading.sees(question)) await reading.summaryOf(question),
|
||||
];
|
||||
}
|
||||
|
||||
/// The question [questionId] with its answers and comments, as the caller
|
||||
/// may see them. Throws [NotFoundException] for one they may not see.
|
||||
Future<DiscussionView> read(Session session, UuidValue questionId) async {
|
||||
final viewer = session.authenticated!.authUserId;
|
||||
// SELECT … FROM "discussion_post" WHERE "id" = $1
|
||||
final question = await DiscussionPost.db.findById(session, questionId);
|
||||
if (question == null || question.kind != 'question') {
|
||||
throw NotFoundException(what: 'question');
|
||||
}
|
||||
final reading = await DiscussionReading.of(
|
||||
session,
|
||||
viewer: viewer,
|
||||
questions: [question],
|
||||
);
|
||||
if (!reading.sees(question)) {
|
||||
throw NotFoundException(what: 'question');
|
||||
}
|
||||
return await reading.viewOf(question, session.authenticated!.scopes);
|
||||
}
|
||||
|
||||
/// Why the caller may not ask about the published studyset [publicId];
|
||||
/// null when they may. Throws [NotFoundException] for one never
|
||||
/// published.
|
||||
Future<DiscussionRefusal?> askRefusal(
|
||||
Session session,
|
||||
UuidValue publicId,
|
||||
) async {
|
||||
final published = await publishedStudyset(session, publicId);
|
||||
if (published.withdrawnAt != null) {
|
||||
return DiscussionRefusal.withdrawn;
|
||||
}
|
||||
final reading = await DiscussionReading.of(
|
||||
session,
|
||||
viewer: session.authenticated!.authUserId,
|
||||
questions: const [],
|
||||
on: published,
|
||||
);
|
||||
return reading.refusalOn(published, session.authenticated!.scopes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writing in published studysets' discussions (ADR 0013), and the inbox
|
||||
/// of those a person follows. For people with an identity and a public
|
||||
/// profile, whom the studyset's author hasn't blocked: posts name their
|
||||
/// writer.
|
||||
class DiscussionEndpoint extends Endpoint {
|
||||
@override
|
||||
Set<Scope> get requiredScopes => {identified};
|
||||
|
||||
/// Asks [title], with [body] (rich text), about [target] of the published
|
||||
/// studyset [publicId] (the studyset as a whole when it names no item);
|
||||
/// returns the question's id. Throws
|
||||
/// [DiscussionRefusedException] when the caller may not, the text won't
|
||||
/// do, the release lacks the target or the studyset was withdrawn, and
|
||||
/// [NotFoundException] for a studyset never published.
|
||||
Future<UuidValue> ask(
|
||||
Session session,
|
||||
UuidValue publicId,
|
||||
DiscussionTarget target,
|
||||
String title,
|
||||
String body,
|
||||
) async {
|
||||
final writer = session.authenticated!.authUserId;
|
||||
final published = await publishedStudyset(session, publicId);
|
||||
if (published.withdrawnAt != null) {
|
||||
throw DiscussionRefusedException(reason: DiscussionRefusal.withdrawn);
|
||||
}
|
||||
await _mayWrite(session, writer, published);
|
||||
final checkedTitleText = checkedTitle(title);
|
||||
final checkedBody = checkedRichText(body);
|
||||
// A question about the published studyset as a whole may name no item.
|
||||
final about = target.kind == 'studyset' && target.item.isEmpty
|
||||
? target.copyWith(item: published.studysetId.uuid)
|
||||
: target;
|
||||
final topics = await topicsOf(published, about);
|
||||
final now = clock.now().toUtc();
|
||||
return await session.db.transaction((transaction) async {
|
||||
final question = await DiscussionPost.db.insertRow(
|
||||
session,
|
||||
DiscussionPost(
|
||||
publishedStudysetId: published.id!,
|
||||
kind: 'question',
|
||||
authUserId: writer,
|
||||
targetKind: about.kind,
|
||||
targetItem: about.item,
|
||||
targetVersion: about.version,
|
||||
topics: topics,
|
||||
title: checkedTitleText,
|
||||
body: checkedBody,
|
||||
createdAt: now,
|
||||
activeAt: now,
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
await markRead(session, writer, question.id!, now, transaction);
|
||||
return question.id!;
|
||||
});
|
||||
}
|
||||
|
||||
/// Answers the question [questionId] with [body] (rich text); returns the
|
||||
/// answer's id. Throws [NotFoundException] for a question the caller
|
||||
/// can't see, or one deleted, and [DiscussionRefusedException] when they
|
||||
/// may not write or the text won't do.
|
||||
Future<UuidValue> answer(
|
||||
Session session,
|
||||
UuidValue questionId,
|
||||
String body,
|
||||
) async {
|
||||
final writer = session.authenticated!.authUserId;
|
||||
final question = await _visible(session, writer, questionId);
|
||||
if (question.kind != 'question' || question.deletedAt != null) {
|
||||
throw NotFoundException(what: 'question');
|
||||
}
|
||||
await _mayWrite(session, writer, await _publishedOf(session, question));
|
||||
final checkedBody = checkedRichText(body);
|
||||
return await _reply(
|
||||
session,
|
||||
question,
|
||||
DiscussionPost(
|
||||
publishedStudysetId: question.publishedStudysetId,
|
||||
kind: 'answer',
|
||||
questionId: question.id,
|
||||
authUserId: writer,
|
||||
body: checkedBody,
|
||||
createdAt: clock.now().toUtc(),
|
||||
activeAt: clock.now().toUtc(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Comments [text] (plain text) on [onId], a question or an answer;
|
||||
/// returns the comment's id. Throws [NotFoundException] for something the
|
||||
/// caller can't see, a comment, or something deleted, and
|
||||
/// [DiscussionRefusedException] when they may not write or the text won't
|
||||
/// do.
|
||||
Future<UuidValue> comment(
|
||||
Session session,
|
||||
UuidValue onId,
|
||||
String text,
|
||||
) async {
|
||||
final writer = session.authenticated!.authUserId;
|
||||
final on = await _visible(session, writer, onId);
|
||||
if (on.kind == 'comment' || on.deletedAt != null) {
|
||||
throw NotFoundException(what: 'post');
|
||||
}
|
||||
final question = on.kind == 'question'
|
||||
? on
|
||||
: await _visible(session, writer, on.questionId!);
|
||||
if (question.deletedAt != null) {
|
||||
throw NotFoundException(what: 'post');
|
||||
}
|
||||
await _mayWrite(session, writer, await _publishedOf(session, question));
|
||||
final checkedText = checkedComment(text);
|
||||
return await _reply(
|
||||
session,
|
||||
question,
|
||||
DiscussionPost(
|
||||
publishedStudysetId: question.publishedStudysetId,
|
||||
kind: 'comment',
|
||||
questionId: question.id,
|
||||
parentId: on.id,
|
||||
authUserId: writer,
|
||||
body: checkedText,
|
||||
createdAt: clock.now().toUtc(),
|
||||
activeAt: clock.now().toUtc(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Changes what the caller's post [postId] says to [body] (rich text, a
|
||||
/// comment's plain text), a question's title to [title] where given.
|
||||
/// Throws [NotFoundException] for a post that isn't theirs, or one
|
||||
/// deleted, and [DiscussionRefusedException] when the text won't do.
|
||||
Future<void> edit(
|
||||
Session session,
|
||||
UuidValue postId,
|
||||
String body, {
|
||||
String? title,
|
||||
}) async {
|
||||
final post = await _own(session, postId);
|
||||
final now = clock.now().toUtc();
|
||||
final edited = post.copyWith(
|
||||
title: post.kind == 'question' && title != null
|
||||
? checkedTitle(title)
|
||||
: post.title,
|
||||
body: post.kind == 'comment'
|
||||
? checkedComment(body)
|
||||
: checkedRichText(body),
|
||||
editedAt: now,
|
||||
);
|
||||
await session.db.transaction((transaction) async {
|
||||
// UPDATE "discussion_post" SET "title", "body", "editedAt" WHERE "id"
|
||||
await DiscussionPost.db.updateRow(
|
||||
session,
|
||||
edited,
|
||||
columns: (t) => [t.title, t.body, t.editedAt],
|
||||
transaction: transaction,
|
||||
);
|
||||
await _active(session, post.questionId ?? post.id!, now, transaction);
|
||||
await markRead(
|
||||
session,
|
||||
post.authUserId!,
|
||||
post.questionId ?? post.id!,
|
||||
now,
|
||||
transaction,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Deletes the caller's post [postId]: for good when nobody else replied
|
||||
/// under it, else its title, text and writer go and it stays for the
|
||||
/// replies. Throws [NotFoundException] for a post that isn't theirs.
|
||||
Future<void> delete(Session session, UuidValue postId) async {
|
||||
final post = await _own(session, postId);
|
||||
await session.db.transaction(
|
||||
(transaction) =>
|
||||
deleteDiscussionPost(session, post, transaction: transaction),
|
||||
);
|
||||
}
|
||||
|
||||
/// Accepts [answerId] as the answer to the caller's question
|
||||
/// [questionId], in place of any before; null accepts none. Throws
|
||||
/// [NotFoundException] for a question that isn't theirs, or an answer
|
||||
/// that isn't one of its own.
|
||||
Future<void> accept(
|
||||
Session session,
|
||||
UuidValue questionId,
|
||||
UuidValue? answerId,
|
||||
) async {
|
||||
final question = await _own(session, questionId);
|
||||
if (question.kind != 'question') {
|
||||
throw NotFoundException(what: 'question');
|
||||
}
|
||||
if (answerId != null) {
|
||||
// SELECT … FROM "discussion_post" WHERE "id" = $1
|
||||
final answer = await DiscussionPost.db.findById(session, answerId);
|
||||
if (answer == null ||
|
||||
answer.kind != 'answer' ||
|
||||
answer.questionId != question.id ||
|
||||
answer.deletedAt != null) {
|
||||
throw NotFoundException(what: 'answer');
|
||||
}
|
||||
}
|
||||
final now = clock.now().toUtc();
|
||||
await session.db.transaction((transaction) async {
|
||||
// UPDATE "discussion_post" SET "acceptedAnswerId", "acceptedAt",
|
||||
// "activeAt" WHERE "id" = $1
|
||||
await DiscussionPost.db.updateRow(
|
||||
session,
|
||||
question.copyWith(
|
||||
acceptedAnswerId: answerId,
|
||||
acceptedAt: answerId == null ? null : now,
|
||||
activeAt: now,
|
||||
),
|
||||
columns: (t) => [t.acceptedAnswerId, t.acceptedAt, t.activeAt],
|
||||
transaction: transaction,
|
||||
);
|
||||
await markRead(
|
||||
session,
|
||||
question.authUserId!,
|
||||
question.id!,
|
||||
now,
|
||||
transaction,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Hides the post [postId] on one of the caller's published studysets
|
||||
/// ([hidden]), or shows it again: hidden, only its writer and the author
|
||||
/// see it. Throws [NotFoundException] for a post on anyone else's.
|
||||
Future<void> hide(Session session, UuidValue postId, bool hidden) async {
|
||||
final author = session.authenticated!.authUserId;
|
||||
// SELECT … FROM "discussion_post" WHERE "id" = $1
|
||||
final post = await DiscussionPost.db.findById(session, postId);
|
||||
if (post == null ||
|
||||
(await _publishedOf(session, post)).authUserId != author) {
|
||||
throw NotFoundException(what: 'post');
|
||||
}
|
||||
// UPDATE "discussion_post" SET "hiddenAt" WHERE "id" = $1
|
||||
await DiscussionPost.db.updateRow(
|
||||
session,
|
||||
post.copyWith(hiddenAt: hidden ? clock.now().toUtc() : null),
|
||||
columns: (t) => [t.hiddenAt],
|
||||
);
|
||||
}
|
||||
|
||||
/// The discussions the caller follows, those last active first: those on
|
||||
/// their published studysets, and those they asked, answered or commented
|
||||
/// in; each saying whether others wrote in it since they last read it.
|
||||
Future<List<DiscussionSummary>> inbox(Session session) async {
|
||||
final viewer = session.authenticated!.authUserId;
|
||||
// SELECT "id" FROM "published_studyset" WHERE "authUserId" = $1
|
||||
final mine = <int>{
|
||||
for (final published in await PublishedStudyset.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(viewer),
|
||||
))
|
||||
published.id!,
|
||||
};
|
||||
// SELECT "questionId" FROM "discussion_post" WHERE "authUserId" = $1
|
||||
final wroteIn = <UuidValue>{
|
||||
for (final post in await DiscussionPost.db.find(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.authUserId.equals(viewer) & t.questionId.notEquals(null),
|
||||
))
|
||||
post.questionId!,
|
||||
};
|
||||
// SELECT … FROM "discussion_post" WHERE "kind" = 'question' AND
|
||||
// ("publishedStudysetId" = ANY($1) OR "authUserId" = $2
|
||||
// OR "id" = ANY($3)) ORDER BY "activeAt" DESC LIMIT 100
|
||||
final questions = await DiscussionPost.db.find(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.kind.equals('question') &
|
||||
(t.publishedStudysetId.inSet(mine) |
|
||||
t.authUserId.equals(viewer) |
|
||||
t.id.inSet(wroteIn)),
|
||||
orderByList: (t) => [t.activeAt.desc()],
|
||||
limit: 100,
|
||||
);
|
||||
final reading = await DiscussionReading.of(
|
||||
session,
|
||||
viewer: viewer,
|
||||
questions: questions,
|
||||
);
|
||||
return [
|
||||
for (final question in questions)
|
||||
if (reading.sees(question)) await reading.summaryOf(question),
|
||||
];
|
||||
}
|
||||
|
||||
/// How many discussions of the caller's inbox have something new.
|
||||
Future<int> unseen(Session session) async =>
|
||||
(await inbox(session)).where((summary) => summary.unseen).length;
|
||||
|
||||
/// Records that the caller has read the discussion [questionId] as it is
|
||||
/// now. Throws [NotFoundException] for a question there isn't.
|
||||
Future<void> markSeen(Session session, UuidValue questionId) async {
|
||||
final viewer = session.authenticated!.authUserId;
|
||||
await session.db.transaction((transaction) async {
|
||||
// SELECT … FROM "discussion_post" WHERE "id" = $1
|
||||
final question = await DiscussionPost.db.findById(
|
||||
session,
|
||||
questionId,
|
||||
transaction: transaction,
|
||||
);
|
||||
if (question == null || question.kind != 'question') {
|
||||
throw NotFoundException(what: 'question');
|
||||
}
|
||||
await markRead(
|
||||
session,
|
||||
viewer,
|
||||
questionId,
|
||||
clock.now().toUtc(),
|
||||
transaction,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Inserts [reply] in [question], which is then active.
|
||||
Future<UuidValue> _reply(
|
||||
Session session,
|
||||
DiscussionPost question,
|
||||
DiscussionPost reply,
|
||||
) => session.db.transaction((transaction) async {
|
||||
final inserted = await DiscussionPost.db.insertRow(
|
||||
session,
|
||||
reply,
|
||||
transaction: transaction,
|
||||
);
|
||||
await _active(session, question.id!, reply.createdAt, transaction);
|
||||
await markRead(
|
||||
session,
|
||||
reply.authUserId!,
|
||||
question.id!,
|
||||
reply.createdAt,
|
||||
transaction,
|
||||
);
|
||||
return inserted.id!;
|
||||
});
|
||||
|
||||
/// Refuses [writer] on [published] when they may not write there, or
|
||||
/// have written as much as an hour allows.
|
||||
Future<void> _mayWrite(
|
||||
Session session,
|
||||
UuidValue writer,
|
||||
PublishedStudyset published,
|
||||
) async {
|
||||
final reading = await DiscussionReading.of(
|
||||
session,
|
||||
viewer: writer,
|
||||
questions: const [],
|
||||
on: published,
|
||||
);
|
||||
if (reading.refusalOn(published, session.authenticated!.scopes)
|
||||
case final refusal?) {
|
||||
throw DiscussionRefusedException(reason: refusal);
|
||||
}
|
||||
// SELECT count(*) FROM "discussion_post"
|
||||
// WHERE "authUserId" = $1 AND "createdAt" > $2
|
||||
final lastHour = await DiscussionPost.db.count(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.authUserId.equals(writer) &
|
||||
(t.createdAt >
|
||||
clock.now().toUtc().subtract(const Duration(hours: 1))),
|
||||
);
|
||||
if (lastHour >= maxPostsAnHour) {
|
||||
throw DiscussionRefusedException(reason: DiscussionRefusal.tooMany);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The published studyset [publicId]; throws [NotFoundException] for one
|
||||
/// never published.
|
||||
Future<PublishedStudyset> publishedStudyset(
|
||||
Session session,
|
||||
UuidValue publicId,
|
||||
) async {
|
||||
// SELECT … FROM "published_studyset" WHERE "publicId" = $1
|
||||
final published = await PublishedStudyset.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.publicId.equals(publicId),
|
||||
);
|
||||
if (published == null) {
|
||||
throw NotFoundException(what: 'studyset');
|
||||
}
|
||||
return published;
|
||||
}
|
||||
|
||||
Future<PublishedStudyset> _publishedOf(
|
||||
Session session,
|
||||
DiscussionPost post,
|
||||
) async =>
|
||||
// SELECT … FROM "published_studyset" WHERE "id" = $1
|
||||
(await PublishedStudyset.db.findById(session, post.publishedStudysetId))!;
|
||||
|
||||
/// The post [postId], when [viewer] sees it and what it is in; throws
|
||||
/// [NotFoundException] otherwise.
|
||||
Future<DiscussionPost> _visible(
|
||||
Session session,
|
||||
UuidValue viewer,
|
||||
UuidValue postId,
|
||||
) async {
|
||||
// SELECT … FROM "discussion_post" WHERE "id" = $1
|
||||
final post = await DiscussionPost.db.findById(session, postId);
|
||||
final question = post == null || post.questionId == null
|
||||
? post
|
||||
: await DiscussionPost.db.findById(session, post.questionId!);
|
||||
if (post == null || question == null) {
|
||||
throw NotFoundException(what: 'post');
|
||||
}
|
||||
final reading = await DiscussionReading.of(
|
||||
session,
|
||||
viewer: viewer,
|
||||
questions: [question],
|
||||
);
|
||||
if (!reading.sees(question) ||
|
||||
(post != question &&
|
||||
!reading.visibleIn(question).any((each) => each.id == post.id))) {
|
||||
throw NotFoundException(what: 'post');
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
/// The caller's own post [postId], not deleted; throws [NotFoundException]
|
||||
/// for any other.
|
||||
Future<DiscussionPost> _own(Session session, UuidValue postId) async {
|
||||
final writer = session.authenticated!.authUserId;
|
||||
// SELECT … FROM "discussion_post" WHERE "id" = $1 AND "authUserId" = $2
|
||||
final post = await DiscussionPost.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.id.equals(postId) & t.authUserId.equals(writer),
|
||||
);
|
||||
if (post == null || post.deletedAt != null) {
|
||||
throw NotFoundException(what: 'post');
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
/// Marks the question [questionId] active [at].
|
||||
Future<void> _active(
|
||||
Session session,
|
||||
UuidValue questionId,
|
||||
DateTime at,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
// UPDATE "discussion_post" SET "activeAt" = $2 WHERE "id" = $1
|
||||
await DiscussionPost.db.updateWhere(
|
||||
session,
|
||||
columnValues: (t) => [t.activeAt(at)],
|
||||
where: (t) => t.id.equals(questionId),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
### A question, an answer or a comment in a published studyset's discussions
|
||||
### (ADR 0013): written through the instance's endpoints and held by it, not
|
||||
### synced, for everyone signed in to read. Server only: the app gets views.
|
||||
class: DiscussionPost
|
||||
serverOnly: true
|
||||
table: discussion_post
|
||||
fields:
|
||||
### A UUID v7, so ids count nothing.
|
||||
id: UuidValue?, defaultPersist=random_v7
|
||||
### The published studyset discussed; its discussions go with it.
|
||||
publishedStudysetId: int, relation(parent=published_studyset, onDelete=Cascade)
|
||||
### 'question', 'answer' or 'comment'.
|
||||
kind: String
|
||||
### The question an answer or a comment is in; null for a question. What
|
||||
### is in a question goes with it.
|
||||
questionId: UuidValue?, relation(parent=discussion_post, onDelete=Cascade)
|
||||
### What a comment is on, its question or an answer; null otherwise.
|
||||
parentId: UuidValue?, relation(parent=discussion_post, onDelete=Cascade)
|
||||
### Who wrote it; null once it is deleted while others' replies are under
|
||||
### it, which keep it, its title and text gone.
|
||||
authUserId: UuidValue?
|
||||
### A question's: what it is about, as the release names it ('studyset'
|
||||
### for the published studyset or a topic in it, 'card', 'note', 'step'),
|
||||
### the item's id, and the release.
|
||||
targetKind: String?
|
||||
targetItem: String?
|
||||
targetVersion: int?
|
||||
### A question's topics, between bars: the studysets from the published
|
||||
### one down to the one its target is in (`|<id>|<id>|`), so a topic lists
|
||||
### what is asked within it.
|
||||
topics: String?
|
||||
### A question's title.
|
||||
title: String?
|
||||
### What it says: rich text (the app's document JSON) for a question or an
|
||||
### answer, plain text for a comment; empty once deleted.
|
||||
body: String
|
||||
### When it was written and last edited, in UTC.
|
||||
createdAt: DateTime
|
||||
editedAt: DateTime?
|
||||
### A question's: when anything last happened in it (a post, an edit, an
|
||||
### answer accepted), in UTC, which lists are ordered by; else when it was
|
||||
### written.
|
||||
activeAt: DateTime
|
||||
### When its writer deleted it, in UTC, while others' replies are under it.
|
||||
deletedAt: DateTime?
|
||||
### When the studyset's author hid it, in UTC: then only its writer and the
|
||||
### author see it.
|
||||
hiddenAt: DateTime?
|
||||
### A question's answer its asker accepted, and when, in UTC.
|
||||
acceptedAnswerId: UuidValue?
|
||||
acceptedAt: DateTime?
|
||||
indexes:
|
||||
# A published studyset's questions.
|
||||
discussion_post_studyset_idx:
|
||||
fields: publishedStudysetId, kind
|
||||
# What is in a question.
|
||||
discussion_post_question_idx:
|
||||
fields: questionId
|
||||
# What a person wrote: their inbox, deleting them, how much they post.
|
||||
discussion_post_writer_idx:
|
||||
fields: authUserId
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
### A question, an answer or a comment as the app shows it.
|
||||
class: DiscussionPostView
|
||||
fields:
|
||||
id: UuidValue
|
||||
### 'question', 'answer' or 'comment'.
|
||||
kind: String
|
||||
### What a comment is on, its question or an answer; null otherwise.
|
||||
parentId: UuidValue?
|
||||
### Who wrote it, as their public profile shows them; null once deleted,
|
||||
### or when its writer has no public profile any more.
|
||||
by: FeedbackPerson?
|
||||
### Whether its writer is the published studyset's author.
|
||||
fromAuthor: bool
|
||||
### A question's title.
|
||||
title: String?
|
||||
### What it says: rich text (the document's JSON), a comment's plain text;
|
||||
### empty once deleted.
|
||||
body: String
|
||||
### When it was written and last edited, in UTC.
|
||||
createdAt: DateTime
|
||||
editedAt: DateTime?
|
||||
deleted: bool
|
||||
### Whether the author hid it: then only its writer and the author see it.
|
||||
hidden: bool
|
||||
### Whether the viewer wrote it.
|
||||
mine: bool
|
||||
291
halkyon_learn_server/lib/src/discussion/discussion_read.dart
Normal file
291
halkyon_learn_server/lib/src/discussion/discussion_read.dart
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import 'package:halkyon_learn_server/src/auth/scopes.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
// What a person sees of a published studyset's discussions (ADR 0013), and
|
||||
// whether they may write in them.
|
||||
//
|
||||
// Everyone signed in reads them, but for what the studyset's author hid
|
||||
// (then only its writer and the author see it) and what a person the author
|
||||
// blocked wrote (then only they see it). A comment shows only with what it
|
||||
// is on, and what is in a question only with the question.
|
||||
|
||||
/// Some questions with what is in them, as one person ([viewer]) may see
|
||||
/// them.
|
||||
final class DiscussionReading {
|
||||
DiscussionReading._(this._session, this.viewer);
|
||||
|
||||
/// Reads what is in [questions], and who wrote it, with the published
|
||||
/// studysets they are on and [on].
|
||||
static Future<DiscussionReading> of(
|
||||
Session session, {
|
||||
required UuidValue viewer,
|
||||
required List<DiscussionPost> questions,
|
||||
PublishedStudyset? on,
|
||||
}) async {
|
||||
final reading = DiscussionReading._(session, viewer);
|
||||
final ids = <UuidValue>{for (final question in questions) question.id!};
|
||||
// SELECT … FROM "discussion_post" WHERE "questionId" = ANY($1)
|
||||
for (final post
|
||||
in ids.isEmpty
|
||||
? const <DiscussionPost>[]
|
||||
: await DiscussionPost.db.find(
|
||||
session,
|
||||
where: (t) => t.questionId.inSet(ids),
|
||||
orderBy: (t) => t.createdAt,
|
||||
)) {
|
||||
(reading._inQuestion[post.questionId!] ??= []).add(post);
|
||||
}
|
||||
if (on != null) {
|
||||
reading._published[on.id!] = on;
|
||||
}
|
||||
// SELECT … FROM "published_studyset" WHERE "id" = ANY($1)
|
||||
for (final published in await PublishedStudyset.db.find(
|
||||
session,
|
||||
where: (t) => t.id.inSet(<int>{
|
||||
for (final question in questions) question.publishedStudysetId,
|
||||
}),
|
||||
)) {
|
||||
reading._published[published.id!] = published;
|
||||
}
|
||||
// The latest release's title of each.
|
||||
// SELECT … FROM "studyset_release" WHERE "publishedStudysetId" = ANY($1)
|
||||
final versions = <int, int>{};
|
||||
for (final release in await StudysetRelease.db.find(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.publishedStudysetId.inSet(reading._published.keys.toSet()),
|
||||
)) {
|
||||
if (release.version > (versions[release.publishedStudysetId] ?? 0)) {
|
||||
versions[release.publishedStudysetId] = release.version;
|
||||
reading._titles[release.publishedStudysetId] = release.title;
|
||||
}
|
||||
}
|
||||
final people = {
|
||||
viewer,
|
||||
for (final published in reading._published.values) published.authUserId,
|
||||
for (final question in questions) ?question.authUserId,
|
||||
for (final posts in reading._inQuestion.values)
|
||||
for (final post in posts) ?post.authUserId,
|
||||
};
|
||||
// SELECT … FROM "public_profile" WHERE "authUserId" = ANY($1)
|
||||
for (final profile in await PublicProfile.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.inSet(people),
|
||||
)) {
|
||||
reading._profiles[profile.authUserId] = profile;
|
||||
}
|
||||
// SELECT … FROM "feedback_block" WHERE "authorId" = ANY($1)
|
||||
for (final block in await FeedbackBlock.db.find(
|
||||
session,
|
||||
where: (t) => t.authorId.inSet(<UuidValue>{
|
||||
for (final published in reading._published.values) published.authUserId,
|
||||
}),
|
||||
)) {
|
||||
reading._blocked.add((block.authorId, block.blockedId));
|
||||
}
|
||||
// SELECT … FROM "discussion_seen"
|
||||
// WHERE "authUserId" = $1 AND "questionId" = ANY($2)
|
||||
for (final seen
|
||||
in ids.isEmpty
|
||||
? const <DiscussionSeen>[]
|
||||
: await DiscussionSeen.db.find(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.authUserId.equals(viewer) & t.questionId.inSet(ids),
|
||||
)) {
|
||||
reading._seen[seen.questionId] = seen.seenAt;
|
||||
}
|
||||
return reading;
|
||||
}
|
||||
|
||||
final Session _session;
|
||||
|
||||
/// Who reads.
|
||||
final UuidValue viewer;
|
||||
|
||||
final _inQuestion = <UuidValue, List<DiscussionPost>>{};
|
||||
final _published = <int, PublishedStudyset>{};
|
||||
final _titles = <int, String>{};
|
||||
final _profiles = <UuidValue, PublicProfile>{};
|
||||
final _blocked = <(UuidValue, UuidValue)>{};
|
||||
final _seen = <UuidValue, DateTime>{};
|
||||
final _avatars = <String, String>{};
|
||||
|
||||
/// The published studyset [post] is on.
|
||||
PublishedStudyset publishedOf(DiscussionPost post) =>
|
||||
_published[post.publishedStudysetId]!;
|
||||
|
||||
/// Whether [viewer] sees [post], leaving aside what it is in.
|
||||
bool sees(DiscussionPost post) {
|
||||
final author = publishedOf(post).authUserId;
|
||||
final writer = post.authUserId;
|
||||
if (writer != null &&
|
||||
writer != viewer &&
|
||||
_blocked.contains((author, writer))) {
|
||||
return false;
|
||||
}
|
||||
return post.hiddenAt == null || writer == viewer || author == viewer;
|
||||
}
|
||||
|
||||
/// What is in [question] that [viewer] sees: its answers, and the
|
||||
/// comments on it and on those answers.
|
||||
List<DiscussionPost> visibleIn(DiscussionPost question) {
|
||||
final posts = _inQuestion[question.id] ?? const <DiscussionPost>[];
|
||||
final answers = {
|
||||
for (final post in posts)
|
||||
if (post.kind == 'answer' && sees(post)) post.id!,
|
||||
};
|
||||
return [
|
||||
for (final post in posts)
|
||||
if (sees(post) &&
|
||||
(post.kind == 'answer' ||
|
||||
post.parentId == question.id ||
|
||||
answers.contains(post.parentId)))
|
||||
post,
|
||||
];
|
||||
}
|
||||
|
||||
/// Why [viewer], with [scopes], may not write on [published]; null when
|
||||
/// they may.
|
||||
DiscussionRefusal? refusalOn(
|
||||
PublishedStudyset published,
|
||||
Set<Scope> scopes,
|
||||
) {
|
||||
if (!scopes.contains(identified)) {
|
||||
return DiscussionRefusal.noIdentity;
|
||||
}
|
||||
if (!_profiles.containsKey(viewer)) {
|
||||
return DiscussionRefusal.noProfile;
|
||||
}
|
||||
if (_blocked.contains((published.authUserId, viewer))) {
|
||||
return DiscussionRefusal.blocked;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// [question] as a list shows it.
|
||||
Future<DiscussionSummary> summaryOf(DiscussionPost question) async {
|
||||
final published = publishedOf(question);
|
||||
final author = published.authUserId;
|
||||
final within = visibleIn(question);
|
||||
final answers = [
|
||||
for (final post in within)
|
||||
if (post.kind == 'answer' && post.deletedAt == null) post,
|
||||
];
|
||||
final follows =
|
||||
author == viewer ||
|
||||
question.authUserId == viewer ||
|
||||
(_inQuestion[question.id] ?? const []).any(
|
||||
(post) => post.authUserId == viewer,
|
||||
);
|
||||
// What others did in it: what is new to the viewer.
|
||||
final theirs = [
|
||||
if (question.authUserId != viewer) question.createdAt,
|
||||
for (final post in within)
|
||||
if (post.authUserId != viewer) post.createdAt,
|
||||
if (question.authUserId != viewer) ?question.acceptedAt,
|
||||
];
|
||||
final seen = _seen[question.id];
|
||||
return DiscussionSummary(
|
||||
questionId: question.id!,
|
||||
publicId: published.publicId,
|
||||
studysetTitle: _titles[published.id] ?? '',
|
||||
title: question.title ?? '',
|
||||
asker: await _personOf(question.authUserId),
|
||||
askerIsAuthor: question.authUserId == author,
|
||||
target: _targetOf(question),
|
||||
createdAt: question.createdAt,
|
||||
activeAt: question.activeAt,
|
||||
answers: answers.length,
|
||||
accepted: answers.any((answer) => answer.id == question.acceptedAnswerId),
|
||||
deleted: question.deletedAt != null,
|
||||
hidden: question.hiddenAt != null,
|
||||
mine: question.authUserId == viewer,
|
||||
unseen: follows && theirs.any((at) => seen == null || at.isAfter(seen)),
|
||||
);
|
||||
}
|
||||
|
||||
/// [question] with what is in it, as [viewer], with [scopes], sees it.
|
||||
Future<DiscussionView> viewOf(
|
||||
DiscussionPost question,
|
||||
Set<Scope> scopes,
|
||||
) async {
|
||||
final published = publishedOf(question);
|
||||
final within = visibleIn(question);
|
||||
final accepted = within
|
||||
.where(
|
||||
(post) =>
|
||||
post.id == question.acceptedAnswerId && post.deletedAt == null,
|
||||
)
|
||||
.firstOrNull;
|
||||
final answers = [
|
||||
?accepted,
|
||||
for (final post in within)
|
||||
if (post.kind == 'answer' && post != accepted) post,
|
||||
];
|
||||
return DiscussionView(
|
||||
question: await _postView(question),
|
||||
publicId: published.publicId,
|
||||
studysetTitle: _titles[published.id] ?? '',
|
||||
location:
|
||||
'${accountDomain(_session.serverpod)}/@'
|
||||
'${_profiles[published.authUserId]?.handle ?? ''}/${published.slug}',
|
||||
target: _targetOf(question),
|
||||
answers: [for (final answer in answers) await _postView(answer)],
|
||||
comments: [
|
||||
for (final post in within)
|
||||
if (post.kind == 'comment') await _postView(post),
|
||||
],
|
||||
acceptedAnswerId: accepted?.id,
|
||||
asker: question.authUserId == viewer,
|
||||
author: published.authUserId == viewer,
|
||||
refusal: refusalOn(published, scopes),
|
||||
);
|
||||
}
|
||||
|
||||
Future<DiscussionPostView> _postView(DiscussionPost post) async =>
|
||||
DiscussionPostView(
|
||||
id: post.id!,
|
||||
kind: post.kind,
|
||||
parentId: post.parentId,
|
||||
by: await _personOf(post.authUserId),
|
||||
fromAuthor: post.authUserId == publishedOf(post).authUserId,
|
||||
title: post.title,
|
||||
body: post.body,
|
||||
createdAt: post.createdAt,
|
||||
editedAt: post.editedAt,
|
||||
deleted: post.deletedAt != null,
|
||||
hidden: post.hiddenAt != null,
|
||||
mine: post.authUserId == viewer,
|
||||
);
|
||||
|
||||
static DiscussionTarget _targetOf(DiscussionPost question) =>
|
||||
DiscussionTarget(
|
||||
kind: question.targetKind ?? 'studyset',
|
||||
item: question.targetItem ?? '',
|
||||
version: question.targetVersion ?? 0,
|
||||
);
|
||||
|
||||
/// [person] as their public profile shows them; null for nobody, or a
|
||||
/// person without one.
|
||||
Future<FeedbackPerson?> _personOf(UuidValue? person) async {
|
||||
final profile = person == null ? null : _profiles[person];
|
||||
if (profile == null) {
|
||||
return null;
|
||||
}
|
||||
return FeedbackPerson(
|
||||
handle: profile.handle,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: switch (profile.avatarHash) {
|
||||
final hash? =>
|
||||
_avatars[hash] ??= (await _session.storage.publicDownloadUrl(
|
||||
storageId: avatarStorage,
|
||||
path: avatarPath(hash),
|
||||
)).toString(),
|
||||
null => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
### Why writing in a discussion was refused; nothing changed.
|
||||
enum: DiscussionRefusal
|
||||
serialized: byName
|
||||
values:
|
||||
### The person signed in without an identity: anything involving other
|
||||
### people needs one.
|
||||
- noIdentity
|
||||
### The person has no public profile: posts name their writer.
|
||||
- noProfile
|
||||
### The studyset's author keeps the person from writing on their
|
||||
### studysets.
|
||||
- blocked
|
||||
### The author took the studyset off Explore: what is there stays
|
||||
### readable, but nobody asks anything new.
|
||||
- withdrawn
|
||||
### The release doesn't hold what the question would be about.
|
||||
- notInRelease
|
||||
### A title or text left empty.
|
||||
- empty
|
||||
### A title, text or comment longer than allowed.
|
||||
- tooLong
|
||||
### More posts in the last hour than a person may write.
|
||||
- tooMany
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
### Writing in a discussion was refused; nothing changed.
|
||||
exception: DiscussionRefusedException
|
||||
fields:
|
||||
### Why it was refused.
|
||||
reason: DiscussionRefusal
|
||||
123
halkyon_learn_server/lib/src/discussion/discussion_rules.dart
Normal file
123
halkyon_learn_server/lib/src/discussion/discussion_rules.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
|
||||
|
||||
// The rules of a published studyset's discussions (ADR 0013): what may be
|
||||
// written, and what a question is about.
|
||||
|
||||
/// The longest a question's title may be, in characters.
|
||||
const maxTitleLength = 120;
|
||||
|
||||
/// The most a question's or an answer's rich text may take, in bytes of its
|
||||
/// JSON.
|
||||
const maxBodyBytes = 50000;
|
||||
|
||||
/// The longest a comment may be, in characters.
|
||||
const maxCommentLength = 600;
|
||||
|
||||
/// The most posts a person may write in an hour.
|
||||
const maxPostsAnHour = 30;
|
||||
|
||||
/// How many questions a page of a list holds.
|
||||
const discussionPage = 30;
|
||||
|
||||
/// What a question may be about, as a release names it.
|
||||
const targetKinds = {'studyset', 'card', 'note', 'step'};
|
||||
|
||||
/// [title] as a question takes it, trimmed; throws
|
||||
/// [DiscussionRefusedException] when it is empty or too long.
|
||||
String checkedTitle(String title) {
|
||||
final trimmed = title.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw DiscussionRefusedException(reason: DiscussionRefusal.empty);
|
||||
}
|
||||
if (trimmed.runes.length > maxTitleLength) {
|
||||
throw DiscussionRefusedException(reason: DiscussionRefusal.tooLong);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/// [body], rich text as the app writes it (a JSON object); throws
|
||||
/// [DiscussionRefusedException] when it is empty, no rich text, or too
|
||||
/// long.
|
||||
String checkedRichText(String body) {
|
||||
if (utf8.encode(body).length > maxBodyBytes) {
|
||||
throw DiscussionRefusedException(reason: DiscussionRefusal.tooLong);
|
||||
}
|
||||
try {
|
||||
if (jsonDecode(body) is Map<String, Object?>) {
|
||||
return body;
|
||||
}
|
||||
} on FormatException {
|
||||
// As empty: nothing in it can be read.
|
||||
}
|
||||
throw DiscussionRefusedException(reason: DiscussionRefusal.empty);
|
||||
}
|
||||
|
||||
/// [text], a comment, trimmed; throws [DiscussionRefusedException] when it
|
||||
/// is empty or too long.
|
||||
String checkedComment(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw DiscussionRefusedException(reason: DiscussionRefusal.empty);
|
||||
}
|
||||
if (trimmed.runes.length > maxCommentLength) {
|
||||
throw DiscussionRefusedException(reason: DiscussionRefusal.tooLong);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/// The topics [target] is within in its release of the
|
||||
/// published studyset [published], between bars: the published studyset's
|
||||
/// id, then each topic's down to the one the target is in, or is (a
|
||||
/// studyset), as the release's directories name them (`|<id>|<id>|`).
|
||||
/// Throws [DiscussionRefusedException] when the release doesn't hold it.
|
||||
Future<String> topicsOf(
|
||||
PublishedStudyset published,
|
||||
DiscussionTarget target,
|
||||
) async {
|
||||
final paths = target.version < 1 || !targetKinds.contains(target.kind)
|
||||
? const <String>[]
|
||||
: await repositoryOf(published.publicId).paths(target.version);
|
||||
final item = target.item;
|
||||
String? directory;
|
||||
if (target.kind == 'studyset' && item == published.studysetId.uuid) {
|
||||
directory = paths.isEmpty ? null : '';
|
||||
} else {
|
||||
for (final path in paths) {
|
||||
final parts = path.split('/');
|
||||
final found = switch (target.kind) {
|
||||
// `<dir>/<id>/studyset.json`
|
||||
'studyset' =>
|
||||
parts.length >= 2 &&
|
||||
parts.last == 'studyset.json' &&
|
||||
parts[parts.length - 2] == item,
|
||||
// `<dir>/cards/<id>.json`, `<dir>/notes/<id>.json`
|
||||
'card' || 'note' =>
|
||||
parts.length >= 2 &&
|
||||
parts[parts.length - 2] == '${target.kind}s' &&
|
||||
parts.last == '$item.json',
|
||||
// `<dir>/paths/<path>/steps/<id>.json`
|
||||
'step' =>
|
||||
parts.length >= 4 &&
|
||||
parts[parts.length - 2] == 'steps' &&
|
||||
parts[parts.length - 4] == 'paths' &&
|
||||
parts.last == '$item.json',
|
||||
_ => false,
|
||||
};
|
||||
if (found) {
|
||||
directory = switch (target.kind) {
|
||||
'studyset' => parts.sublist(0, parts.length - 1).join('/'),
|
||||
'step' => parts.sublist(0, parts.length - 4).join('/'),
|
||||
_ => parts.sublist(0, parts.length - 2).join('/'),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (directory == null) {
|
||||
throw DiscussionRefusedException(reason: DiscussionRefusal.notInRelease);
|
||||
}
|
||||
return '|${[published.studysetId.uuid, ...directory.split('/').where((part) => part.isNotEmpty)].join('|')}|';
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
### When a person last read a discussion, for their inbox to say what is new.
|
||||
### Server only.
|
||||
class: DiscussionSeen
|
||||
serverOnly: true
|
||||
table: discussion_seen
|
||||
fields:
|
||||
authUserId: UuidValue
|
||||
### The question; the row goes with it.
|
||||
questionId: UuidValue, relation(parent=discussion_post, onDelete=Cascade)
|
||||
### When, in UTC.
|
||||
seenAt: DateTime
|
||||
indexes:
|
||||
discussion_seen_pair_idx:
|
||||
fields: authUserId, questionId
|
||||
unique: true
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
### A question as a list shows it.
|
||||
class: DiscussionSummary
|
||||
fields:
|
||||
questionId: UuidValue
|
||||
### The published studyset it is on, and its title.
|
||||
publicId: UuidValue
|
||||
studysetTitle: String
|
||||
### Its title; empty once deleted.
|
||||
title: String
|
||||
### Who asked it; null once deleted, or without a public profile.
|
||||
asker: FeedbackPerson?
|
||||
askerIsAuthor: bool
|
||||
### What it is about.
|
||||
target: DiscussionTarget
|
||||
### When it was asked, and when anything last happened in it, in UTC.
|
||||
createdAt: DateTime
|
||||
activeAt: DateTime
|
||||
### How many answers the viewer sees, and whether one is accepted.
|
||||
answers: int
|
||||
accepted: bool
|
||||
deleted: bool
|
||||
hidden: bool
|
||||
### Whether the viewer asked it.
|
||||
mine: bool
|
||||
### Whether others wrote in it since the viewer last read it, where it is
|
||||
### theirs to follow: they asked, answered or commented, or it is on their
|
||||
### studyset.
|
||||
unseen: bool
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
### What a question is about: the published studyset or a topic in it
|
||||
### ('studyset'), or one of its cards, notes or steps ('card', 'note',
|
||||
### 'step'), by its id in release [version].
|
||||
class: DiscussionTarget
|
||||
fields:
|
||||
kind: String
|
||||
item: String
|
||||
version: int
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
### A discussion as the app shows it: the question, its answers, the accepted
|
||||
### one first, then the oldest first, and the comments on both, oldest first.
|
||||
class: DiscussionView
|
||||
fields:
|
||||
question: DiscussionPostView
|
||||
### The published studyset it is on: its public id, title and location.
|
||||
publicId: UuidValue
|
||||
studysetTitle: String
|
||||
location: String
|
||||
### What it is about.
|
||||
target: DiscussionTarget
|
||||
answers: List<DiscussionPostView>
|
||||
comments: List<DiscussionPostView>
|
||||
acceptedAnswerId: UuidValue?
|
||||
### Whether the viewer asked it, and so may accept an answer.
|
||||
asker: bool
|
||||
### Whether the viewer is the studyset's author, and so may hide posts.
|
||||
author: bool
|
||||
### Why the viewer may not write in it; null when they may.
|
||||
refusal: DiscussionRefusal?
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,239 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart' as _i5lsenq5;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import '../feedback/feedback_person.dart' as _i4q3oto5;
|
||||
|
||||
/// A question, an answer or a comment as the app shows it.
|
||||
abstract class DiscussionPostView
|
||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||
DiscussionPostView._({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
this.parentId,
|
||||
this.by,
|
||||
required this.fromAuthor,
|
||||
this.title,
|
||||
required this.body,
|
||||
required this.createdAt,
|
||||
this.editedAt,
|
||||
required this.deleted,
|
||||
required this.hidden,
|
||||
required this.mine,
|
||||
});
|
||||
|
||||
factory DiscussionPostView({
|
||||
required _is.UuidValue id,
|
||||
required String kind,
|
||||
_is.UuidValue? parentId,
|
||||
_i4q3oto5.FeedbackPerson? by,
|
||||
required bool fromAuthor,
|
||||
String? title,
|
||||
required String body,
|
||||
required DateTime createdAt,
|
||||
DateTime? editedAt,
|
||||
required bool deleted,
|
||||
required bool hidden,
|
||||
required bool mine,
|
||||
}) = _DiscussionPostViewImpl;
|
||||
|
||||
factory DiscussionPostView.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return DiscussionPostView(
|
||||
id: _is.UuidValueJsonExtension.fromJson(jsonSerialization['id']),
|
||||
kind: jsonSerialization['kind'] as String,
|
||||
parentId: jsonSerialization['parentId'] == null
|
||||
? null
|
||||
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['parentId']),
|
||||
by: jsonSerialization['by'] == null
|
||||
? null
|
||||
: _i5lsenq5.Protocol().deserialize<_i4q3oto5.FeedbackPerson>(
|
||||
jsonSerialization['by'],
|
||||
),
|
||||
fromAuthor: _is.BoolJsonExtension.fromJson(
|
||||
jsonSerialization['fromAuthor'],
|
||||
),
|
||||
title: jsonSerialization['title'] as String?,
|
||||
body: jsonSerialization['body'] as String,
|
||||
createdAt: _is.DateTimeJsonExtension.fromJson(
|
||||
jsonSerialization['createdAt'],
|
||||
),
|
||||
editedAt: jsonSerialization['editedAt'] == null
|
||||
? null
|
||||
: _is.DateTimeJsonExtension.fromJson(jsonSerialization['editedAt']),
|
||||
deleted: _is.BoolJsonExtension.fromJson(jsonSerialization['deleted']),
|
||||
hidden: _is.BoolJsonExtension.fromJson(jsonSerialization['hidden']),
|
||||
mine: _is.BoolJsonExtension.fromJson(jsonSerialization['mine']),
|
||||
);
|
||||
}
|
||||
|
||||
_is.UuidValue id;
|
||||
|
||||
/// 'question', 'answer' or 'comment'.
|
||||
String kind;
|
||||
|
||||
/// What a comment is on, its question or an answer; null otherwise.
|
||||
_is.UuidValue? parentId;
|
||||
|
||||
/// Who wrote it, as their public profile shows them; null once deleted,
|
||||
/// or when its writer has no public profile any more.
|
||||
_i4q3oto5.FeedbackPerson? by;
|
||||
|
||||
/// Whether its writer is the published studyset's author.
|
||||
bool fromAuthor;
|
||||
|
||||
/// A question's title.
|
||||
String? title;
|
||||
|
||||
/// What it says: rich text (the document's JSON), a comment's plain text;
|
||||
/// empty once deleted.
|
||||
String body;
|
||||
|
||||
/// When it was written and last edited, in UTC.
|
||||
DateTime createdAt;
|
||||
|
||||
DateTime? editedAt;
|
||||
|
||||
bool deleted;
|
||||
|
||||
/// Whether the author hid it: then only its writer and the author see it.
|
||||
bool hidden;
|
||||
|
||||
/// Whether the viewer wrote it.
|
||||
bool mine;
|
||||
|
||||
/// Returns a shallow copy of this [DiscussionPostView]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
DiscussionPostView copyWith({
|
||||
_is.UuidValue? id,
|
||||
String? kind,
|
||||
_is.UuidValue? parentId,
|
||||
_i4q3oto5.FeedbackPerson? by,
|
||||
bool? fromAuthor,
|
||||
String? title,
|
||||
String? body,
|
||||
DateTime? createdAt,
|
||||
DateTime? editedAt,
|
||||
bool? deleted,
|
||||
bool? hidden,
|
||||
bool? mine,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'DiscussionPostView',
|
||||
'id': id.toJson(),
|
||||
'kind': kind,
|
||||
if (parentId != null) 'parentId': parentId?.toJson(),
|
||||
if (by != null) 'by': by?.toJson(),
|
||||
'fromAuthor': fromAuthor,
|
||||
if (title != null) 'title': title,
|
||||
'body': body,
|
||||
'createdAt': createdAt.toJson(),
|
||||
if (editedAt != null) 'editedAt': editedAt?.toJson(),
|
||||
'deleted': deleted,
|
||||
'hidden': hidden,
|
||||
'mine': mine,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'DiscussionPostView',
|
||||
'id': id.toJson(),
|
||||
'kind': kind,
|
||||
if (parentId != null) 'parentId': parentId?.toJson(),
|
||||
if (by != null) 'by': by?.toJsonForProtocol(),
|
||||
'fromAuthor': fromAuthor,
|
||||
if (title != null) 'title': title,
|
||||
'body': body,
|
||||
'createdAt': createdAt.toJson(),
|
||||
if (editedAt != null) 'editedAt': editedAt?.toJson(),
|
||||
'deleted': deleted,
|
||||
'hidden': hidden,
|
||||
'mine': mine,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _DiscussionPostViewImpl extends DiscussionPostView {
|
||||
_DiscussionPostViewImpl({
|
||||
required _is.UuidValue id,
|
||||
required String kind,
|
||||
_is.UuidValue? parentId,
|
||||
_i4q3oto5.FeedbackPerson? by,
|
||||
required bool fromAuthor,
|
||||
String? title,
|
||||
required String body,
|
||||
required DateTime createdAt,
|
||||
DateTime? editedAt,
|
||||
required bool deleted,
|
||||
required bool hidden,
|
||||
required bool mine,
|
||||
}) : super._(
|
||||
id: id,
|
||||
kind: kind,
|
||||
parentId: parentId,
|
||||
by: by,
|
||||
fromAuthor: fromAuthor,
|
||||
title: title,
|
||||
body: body,
|
||||
createdAt: createdAt,
|
||||
editedAt: editedAt,
|
||||
deleted: deleted,
|
||||
hidden: hidden,
|
||||
mine: mine,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [DiscussionPostView]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
DiscussionPostView copyWith({
|
||||
_is.UuidValue? id,
|
||||
String? kind,
|
||||
Object? parentId = _Undefined,
|
||||
Object? by = _Undefined,
|
||||
bool? fromAuthor,
|
||||
Object? title = _Undefined,
|
||||
String? body,
|
||||
DateTime? createdAt,
|
||||
Object? editedAt = _Undefined,
|
||||
bool? deleted,
|
||||
bool? hidden,
|
||||
bool? mine,
|
||||
}) {
|
||||
return DiscussionPostView(
|
||||
id: id ?? this.id,
|
||||
kind: kind ?? this.kind,
|
||||
parentId: parentId is _is.UuidValue? ? parentId : this.parentId,
|
||||
by: by is _i4q3oto5.FeedbackPerson? ? by : this.by?.copyWith(),
|
||||
fromAuthor: fromAuthor ?? this.fromAuthor,
|
||||
title: title is String? ? title : this.title,
|
||||
body: body ?? this.body,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
editedAt: editedAt is DateTime? ? editedAt : this.editedAt,
|
||||
deleted: deleted ?? this.deleted,
|
||||
hidden: hidden ?? this.hidden,
|
||||
mine: mine ?? this.mine,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
|
||||
/// Why writing in a discussion was refused; nothing changed.
|
||||
enum DiscussionRefusal implements _is.SerializableModel {
|
||||
/// The person signed in without an identity: anything involving other
|
||||
/// people needs one.
|
||||
noIdentity,
|
||||
|
||||
/// The person has no public profile: posts name their writer.
|
||||
noProfile,
|
||||
|
||||
/// The studyset's author keeps the person from writing on their
|
||||
/// studysets.
|
||||
blocked,
|
||||
|
||||
/// The author took the studyset off Explore: what is there stays
|
||||
/// readable, but nobody asks anything new.
|
||||
withdrawn,
|
||||
|
||||
/// The release doesn't hold what the question would be about.
|
||||
notInRelease,
|
||||
|
||||
/// A title or text left empty.
|
||||
empty,
|
||||
|
||||
/// A title, text or comment longer than allowed.
|
||||
tooLong,
|
||||
|
||||
/// More posts in the last hour than a person may write.
|
||||
tooMany;
|
||||
|
||||
static DiscussionRefusal fromJson(String name) {
|
||||
switch (name) {
|
||||
case 'noIdentity':
|
||||
return DiscussionRefusal.noIdentity;
|
||||
case 'noProfile':
|
||||
return DiscussionRefusal.noProfile;
|
||||
case 'blocked':
|
||||
return DiscussionRefusal.blocked;
|
||||
case 'withdrawn':
|
||||
return DiscussionRefusal.withdrawn;
|
||||
case 'notInRelease':
|
||||
return DiscussionRefusal.notInRelease;
|
||||
case 'empty':
|
||||
return DiscussionRefusal.empty;
|
||||
case 'tooLong':
|
||||
return DiscussionRefusal.tooLong;
|
||||
case 'tooMany':
|
||||
return DiscussionRefusal.tooMany;
|
||||
default:
|
||||
throw ArgumentError(
|
||||
'Value "$name" cannot be converted to "DiscussionRefusal"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toJson() => name;
|
||||
|
||||
@override
|
||||
String toString() => name;
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import '../discussion/discussion_refusal.dart' as _i5ff9exj;
|
||||
|
||||
/// Writing in a discussion was refused; nothing changed.
|
||||
abstract class DiscussionRefusedException
|
||||
implements
|
||||
_is.SerializableException,
|
||||
_is.SerializableModel,
|
||||
_is.ProtocolSerialization {
|
||||
DiscussionRefusedException._({required this.reason});
|
||||
|
||||
factory DiscussionRefusedException({
|
||||
required _i5ff9exj.DiscussionRefusal reason,
|
||||
}) = _DiscussionRefusedExceptionImpl;
|
||||
|
||||
factory DiscussionRefusedException.fromJson(
|
||||
Map<String, dynamic> jsonSerialization,
|
||||
) {
|
||||
return DiscussionRefusedException(
|
||||
reason: _i5ff9exj.DiscussionRefusal.fromJson(
|
||||
(jsonSerialization['reason'] as String),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Why it was refused.
|
||||
_i5ff9exj.DiscussionRefusal reason;
|
||||
|
||||
/// Returns a shallow copy of this [DiscussionRefusedException]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
DiscussionRefusedException copyWith({_i5ff9exj.DiscussionRefusal? reason});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'DiscussionRefusedException',
|
||||
'reason': reason.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'DiscussionRefusedException',
|
||||
'reason': reason.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DiscussionRefusedException(reason: $reason)';
|
||||
}
|
||||
}
|
||||
|
||||
class _DiscussionRefusedExceptionImpl extends DiscussionRefusedException {
|
||||
_DiscussionRefusedExceptionImpl({required _i5ff9exj.DiscussionRefusal reason})
|
||||
: super._(reason: reason);
|
||||
|
||||
/// Returns a shallow copy of this [DiscussionRefusedException]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
DiscussionRefusedException copyWith({_i5ff9exj.DiscussionRefusal? reason}) {
|
||||
return DiscussionRefusedException(reason: reason ?? this.reason);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,633 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
|
||||
/// When a person last read a discussion, for their inbox to say what is new.
|
||||
/// Server only.
|
||||
abstract class DiscussionSeen
|
||||
implements _is.TableRow<int?>, _is.ProtocolSerialization {
|
||||
DiscussionSeen._({
|
||||
this.id,
|
||||
required this.authUserId,
|
||||
required this.questionId,
|
||||
required this.seenAt,
|
||||
});
|
||||
|
||||
factory DiscussionSeen({
|
||||
int? id,
|
||||
required _is.UuidValue authUserId,
|
||||
required _is.UuidValue questionId,
|
||||
required DateTime seenAt,
|
||||
}) = _DiscussionSeenImpl;
|
||||
|
||||
factory DiscussionSeen.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return DiscussionSeen(
|
||||
id: jsonSerialization['id'] as int?,
|
||||
authUserId: _is.UuidValueJsonExtension.fromJson(
|
||||
jsonSerialization['authUserId'],
|
||||
),
|
||||
questionId: _is.UuidValueJsonExtension.fromJson(
|
||||
jsonSerialization['questionId'],
|
||||
),
|
||||
seenAt: _is.DateTimeJsonExtension.fromJson(jsonSerialization['seenAt']),
|
||||
);
|
||||
}
|
||||
|
||||
static final t = DiscussionSeenTable();
|
||||
|
||||
static const db = DiscussionSeenRepository._();
|
||||
|
||||
@override
|
||||
int? id;
|
||||
|
||||
_is.UuidValue authUserId;
|
||||
|
||||
/// The question; the row goes with it.
|
||||
_is.UuidValue questionId;
|
||||
|
||||
/// When, in UTC.
|
||||
DateTime seenAt;
|
||||
|
||||
@override
|
||||
_is.Table<int?> get table => t;
|
||||
|
||||
/// Returns a shallow copy of this [DiscussionSeen]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
DiscussionSeen copyWith({
|
||||
int? id,
|
||||
_is.UuidValue? authUserId,
|
||||
_is.UuidValue? questionId,
|
||||
DateTime? seenAt,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'DiscussionSeen',
|
||||
if (id != null) 'id': id,
|
||||
'authUserId': authUserId.toJson(),
|
||||
'questionId': questionId.toJson(),
|
||||
'seenAt': seenAt.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {};
|
||||
}
|
||||
|
||||
static DiscussionSeenInclude include() {
|
||||
return DiscussionSeenInclude._();
|
||||
}
|
||||
|
||||
static DiscussionSeenIncludeList includeList({
|
||||
_is.WhereExpressionBuilder<DiscussionSeenTable>? where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_is.OrderByBuilder<DiscussionSeenTable>? orderBy,
|
||||
_is.OrderByListBuilder<DiscussionSeenTable>? orderByList,
|
||||
DiscussionSeenInclude? include,
|
||||
}) {
|
||||
return DiscussionSeenIncludeList._(
|
||||
where: where,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
orderBy: orderBy?.call(DiscussionSeen.t),
|
||||
orderByList: orderByList?.call(DiscussionSeen.t),
|
||||
include: include,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _DiscussionSeenImpl extends DiscussionSeen {
|
||||
_DiscussionSeenImpl({
|
||||
int? id,
|
||||
required _is.UuidValue authUserId,
|
||||
required _is.UuidValue questionId,
|
||||
required DateTime seenAt,
|
||||
}) : super._(
|
||||
id: id,
|
||||
authUserId: authUserId,
|
||||
questionId: questionId,
|
||||
seenAt: seenAt,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [DiscussionSeen]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
DiscussionSeen copyWith({
|
||||
Object? id = _Undefined,
|
||||
_is.UuidValue? authUserId,
|
||||
_is.UuidValue? questionId,
|
||||
DateTime? seenAt,
|
||||
}) {
|
||||
return DiscussionSeen(
|
||||
id: id is int? ? id : this.id,
|
||||
authUserId: authUserId ?? this.authUserId,
|
||||
questionId: questionId ?? this.questionId,
|
||||
seenAt: seenAt ?? this.seenAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DiscussionSeenUpdateTable extends _is.UpdateTable<DiscussionSeenTable> {
|
||||
DiscussionSeenUpdateTable(super.table);
|
||||
|
||||
_is.ColumnValue<_is.UuidValue, _is.UuidValue> authUserId(
|
||||
_is.UuidValue value,
|
||||
) => _is.ColumnValue(
|
||||
table.authUserId,
|
||||
value,
|
||||
);
|
||||
|
||||
_is.ColumnValue<_is.UuidValue, _is.UuidValue> questionId(
|
||||
_is.UuidValue value,
|
||||
) => _is.ColumnValue(
|
||||
table.questionId,
|
||||
value,
|
||||
);
|
||||
|
||||
_is.ColumnValue<DateTime, DateTime> seenAt(DateTime value) => _is.ColumnValue(
|
||||
table.seenAt,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
class DiscussionSeenTable extends _is.Table<int?> {
|
||||
DiscussionSeenTable({super.tableRelation})
|
||||
: super(tableName: 'discussion_seen') {
|
||||
updateTable = DiscussionSeenUpdateTable(this);
|
||||
authUserId = _is.ColumnUuid(
|
||||
'authUserId',
|
||||
this,
|
||||
);
|
||||
questionId = _is.ColumnUuid(
|
||||
'questionId',
|
||||
this,
|
||||
);
|
||||
seenAt = _is.ColumnDateTime(
|
||||
'seenAt',
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
late final DiscussionSeenUpdateTable updateTable;
|
||||
|
||||
late final _is.ColumnUuid authUserId;
|
||||
|
||||
/// The question; the row goes with it.
|
||||
late final _is.ColumnUuid questionId;
|
||||
|
||||
/// When, in UTC.
|
||||
late final _is.ColumnDateTime seenAt;
|
||||
|
||||
@override
|
||||
List<_is.Column> get columns => [
|
||||
id,
|
||||
authUserId,
|
||||
questionId,
|
||||
seenAt,
|
||||
];
|
||||
}
|
||||
|
||||
class DiscussionSeenInclude extends _is.IncludeObject {
|
||||
DiscussionSeenInclude._();
|
||||
|
||||
@override
|
||||
Map<String, _is.Include?> get includes => {};
|
||||
|
||||
@override
|
||||
_is.Table<int?> get table => DiscussionSeen.t;
|
||||
}
|
||||
|
||||
class DiscussionSeenIncludeList extends _is.IncludeList {
|
||||
DiscussionSeenIncludeList._({
|
||||
_is.WhereExpressionBuilder<DiscussionSeenTable>? where,
|
||||
super.limit,
|
||||
super.offset,
|
||||
super.orderBy,
|
||||
super.orderByList,
|
||||
super.include,
|
||||
}) {
|
||||
super.where = where?.call(DiscussionSeen.t);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, _is.Include?> get includes => include?.includes ?? {};
|
||||
|
||||
@override
|
||||
_is.Table<int?> get table => DiscussionSeen.t;
|
||||
}
|
||||
|
||||
class DiscussionSeenRepository {
|
||||
const DiscussionSeenRepository._();
|
||||
|
||||
/// Returns a list of [DiscussionSeen]s matching the given query parameters.
|
||||
///
|
||||
/// Use [where] to specify which items to include in the return value.
|
||||
/// If none is specified, all items will be returned.
|
||||
///
|
||||
/// To specify the order of the items use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// The maximum number of items can be set by [limit]. If no limit is set,
|
||||
/// all items matching the query will be returned.
|
||||
///
|
||||
/// [offset] defines how many items to skip, after which [limit] (or all)
|
||||
/// items are read from the database.
|
||||
///
|
||||
/// ```dart
|
||||
/// var persons = await Persons.db.find(
|
||||
/// session,
|
||||
/// where: (t) => t.lastName.equals('Jones'),
|
||||
/// orderBy: (t) => t.firstName,
|
||||
/// limit: 100,
|
||||
/// );
|
||||
/// ```
|
||||
Future<List<DiscussionSeen>> find(
|
||||
_is.DatabaseSession session, {
|
||||
_is.WhereExpressionBuilder<DiscussionSeenTable>? where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_is.OrderByBuilder<DiscussionSeenTable>? orderBy,
|
||||
_is.OrderByListBuilder<DiscussionSeenTable>? orderByList,
|
||||
_is.Transaction? transaction,
|
||||
_is.LockMode? lockMode,
|
||||
_is.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.find<DiscussionSeen>(
|
||||
where: where?.call(DiscussionSeen.t),
|
||||
orderBy: orderBy?.call(DiscussionSeen.t),
|
||||
orderByList: orderByList?.call(DiscussionSeen.t),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
transaction: transaction,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the first matching [DiscussionSeen] matching the given query parameters.
|
||||
///
|
||||
/// Use [where] to specify which items to include in the return value.
|
||||
/// If none is specified, all items will be returned.
|
||||
///
|
||||
/// To specify the order use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// [offset] defines how many items to skip, after which the next one will be picked.
|
||||
///
|
||||
/// ```dart
|
||||
/// var youngestPerson = await Persons.db.findFirstRow(
|
||||
/// session,
|
||||
/// where: (t) => t.lastName.equals('Jones'),
|
||||
/// orderBy: (t) => t.age,
|
||||
/// );
|
||||
/// ```
|
||||
Future<DiscussionSeen?> findFirstRow(
|
||||
_is.DatabaseSession session, {
|
||||
_is.WhereExpressionBuilder<DiscussionSeenTable>? where,
|
||||
int? offset,
|
||||
_is.OrderByBuilder<DiscussionSeenTable>? orderBy,
|
||||
_is.OrderByListBuilder<DiscussionSeenTable>? orderByList,
|
||||
_is.Transaction? transaction,
|
||||
_is.LockMode? lockMode,
|
||||
_is.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.findFirstRow<DiscussionSeen>(
|
||||
where: where?.call(DiscussionSeen.t),
|
||||
orderBy: orderBy?.call(DiscussionSeen.t),
|
||||
orderByList: orderByList?.call(DiscussionSeen.t),
|
||||
offset: offset,
|
||||
transaction: transaction,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Finds a single [DiscussionSeen] by its [id] or null if no such row exists.
|
||||
Future<DiscussionSeen?> findById(
|
||||
_is.DatabaseSession session,
|
||||
int id, {
|
||||
_is.Transaction? transaction,
|
||||
_is.LockMode? lockMode,
|
||||
_is.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.findById<DiscussionSeen>(
|
||||
id,
|
||||
transaction: transaction,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts all [DiscussionSeen]s in the list and returns the inserted rows.
|
||||
///
|
||||
/// The returned [DiscussionSeen]s will have their `id` fields set.
|
||||
///
|
||||
/// This is an atomic operation, meaning that if one of the rows fails to
|
||||
/// insert, none of the rows will be inserted.
|
||||
///
|
||||
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
|
||||
/// rows are silently skipped, and only the successfully inserted rows are
|
||||
/// returned.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the inserted rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<DiscussionSeen>> insert(
|
||||
_is.DatabaseSession session,
|
||||
List<DiscussionSeen> rows, {
|
||||
_is.Transaction? transaction,
|
||||
bool ignoreConflicts = false,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.insert<DiscussionSeen>(
|
||||
rows,
|
||||
transaction: transaction,
|
||||
ignoreConflicts: ignoreConflicts,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts a single [DiscussionSeen] and returns the inserted row.
|
||||
///
|
||||
/// The returned [DiscussionSeen] will have its `id` field set.
|
||||
Future<DiscussionSeen> insertRow(
|
||||
_is.DatabaseSession session,
|
||||
DiscussionSeen row, {
|
||||
_is.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.insertRow<DiscussionSeen>(
|
||||
row,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upserts all [DiscussionSeen]s in the list and returns the resulting rows.
|
||||
///
|
||||
/// If a row conflicts on the given [conflictColumns], the existing row is
|
||||
/// updated with the new values. Otherwise, a new row is inserted.
|
||||
///
|
||||
/// If [updateColumns] is provided, only those columns will be updated on
|
||||
/// conflict. If null, all non-conflict, non-id columns are updated.
|
||||
///
|
||||
/// If [updateWhere] is provided, the update only applies to rows matching the
|
||||
/// given expression. Conflicting rows that don't match are skipped and not
|
||||
/// returned, so the resulting list may be shorter than [rows].
|
||||
///
|
||||
/// The returned [DiscussionSeen]s will have their `id` fields set.
|
||||
///
|
||||
/// This is an atomic operation, meaning that if one of the rows fails,
|
||||
/// none of the rows will be affected.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the resulting rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<DiscussionSeen>> upsert(
|
||||
_is.DatabaseSession session,
|
||||
List<DiscussionSeen> rows, {
|
||||
required _is.ColumnSelections<DiscussionSeenTable> conflictColumns,
|
||||
_is.ColumnSelections<DiscussionSeenTable>? updateColumns,
|
||||
_is.WhereExpressionBuilder<DiscussionSeenTable>? updateWhere,
|
||||
_is.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.upsert<DiscussionSeen>(
|
||||
rows,
|
||||
conflictColumns: conflictColumns(DiscussionSeen.t),
|
||||
updateColumns: updateColumns?.call(DiscussionSeen.t),
|
||||
updateWhere: updateWhere?.call(DiscussionSeen.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upserts a single [DiscussionSeen] and returns the resulting row.
|
||||
///
|
||||
/// If the row conflicts on the given [conflictColumns], the existing row is
|
||||
/// updated. Otherwise, a new row is inserted.
|
||||
///
|
||||
/// If [updateColumns] is provided, only those columns will be updated on
|
||||
/// conflict. If null, all non-conflict, non-id columns are updated.
|
||||
///
|
||||
/// If [updateWhere] is provided, the update only applies when the existing
|
||||
/// row matches the expression. Returns `null` if no row was affected — for
|
||||
/// example when [updateWhere] does not match the conflicting row.
|
||||
///
|
||||
/// The returned [DiscussionSeen] will have its `id` field set.
|
||||
Future<DiscussionSeen?> upsertRow(
|
||||
_is.DatabaseSession session,
|
||||
DiscussionSeen row, {
|
||||
required _is.ColumnSelections<DiscussionSeenTable> conflictColumns,
|
||||
_is.ColumnSelections<DiscussionSeenTable>? updateColumns,
|
||||
_is.WhereExpressionBuilder<DiscussionSeenTable>? updateWhere,
|
||||
_is.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.upsertRow<DiscussionSeen>(
|
||||
row,
|
||||
conflictColumns: conflictColumns(DiscussionSeen.t),
|
||||
updateColumns: updateColumns?.call(DiscussionSeen.t),
|
||||
updateWhere: updateWhere?.call(DiscussionSeen.t),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates all [DiscussionSeen]s in the list and returns the updated rows. If
|
||||
/// [columns] is provided, only those columns will be updated. Defaults to
|
||||
/// all columns.
|
||||
/// This is an atomic operation, meaning that if one of the rows fails to
|
||||
/// update, none of the rows will be updated.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the updated rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<DiscussionSeen>> update(
|
||||
_is.DatabaseSession session,
|
||||
List<DiscussionSeen> rows, {
|
||||
_is.ColumnSelections<DiscussionSeenTable>? columns,
|
||||
_is.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.update<DiscussionSeen>(
|
||||
rows,
|
||||
columns: columns?.call(DiscussionSeen.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a single [DiscussionSeen]. The row needs to have its id set.
|
||||
/// Optionally, a list of [columns] can be provided to only update those
|
||||
/// columns. Defaults to all columns.
|
||||
Future<DiscussionSeen> updateRow(
|
||||
_is.DatabaseSession session,
|
||||
DiscussionSeen row, {
|
||||
_is.ColumnSelections<DiscussionSeenTable>? columns,
|
||||
_is.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.updateRow<DiscussionSeen>(
|
||||
row,
|
||||
columns: columns?.call(DiscussionSeen.t),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a single [DiscussionSeen] by its [id] with the specified [columnValues].
|
||||
/// Returns the updated row or null if no row with the given id exists.
|
||||
Future<DiscussionSeen?> updateById(
|
||||
_is.DatabaseSession session,
|
||||
int id, {
|
||||
required _is.ColumnValueListBuilder<DiscussionSeenUpdateTable> columnValues,
|
||||
_is.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.updateById<DiscussionSeen>(
|
||||
id,
|
||||
columnValues: columnValues(DiscussionSeen.t.updateTable),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates all [DiscussionSeen]s matching the [where] expression with the specified [columnValues].
|
||||
/// Returns the list of updated rows.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the updated rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<DiscussionSeen>> updateWhere(
|
||||
_is.DatabaseSession session, {
|
||||
required _is.ColumnValueListBuilder<DiscussionSeenUpdateTable> columnValues,
|
||||
required _is.WhereExpressionBuilder<DiscussionSeenTable> where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_is.OrderByBuilder<DiscussionSeenTable>? orderBy,
|
||||
_is.OrderByListBuilder<DiscussionSeenTable>? orderByList,
|
||||
_is.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.updateWhere<DiscussionSeen>(
|
||||
columnValues: columnValues(DiscussionSeen.t.updateTable),
|
||||
where: where(DiscussionSeen.t),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
orderBy: orderBy?.call(DiscussionSeen.t),
|
||||
orderByList: orderByList?.call(DiscussionSeen.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes all [DiscussionSeen]s in the list and returns the deleted rows.
|
||||
///
|
||||
/// To specify the order of the returned rows use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// This is an atomic operation, meaning that if one of the rows fail to
|
||||
/// be deleted, none of the rows will be deleted.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the deleted rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<DiscussionSeen>> delete(
|
||||
_is.DatabaseSession session,
|
||||
List<DiscussionSeen> rows, {
|
||||
_is.OrderByBuilder<DiscussionSeenTable>? orderBy,
|
||||
_is.OrderByListBuilder<DiscussionSeenTable>? orderByList,
|
||||
_is.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.delete<DiscussionSeen>(
|
||||
rows,
|
||||
orderBy: orderBy?.call(DiscussionSeen.t),
|
||||
orderByList: orderByList?.call(DiscussionSeen.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes a single [DiscussionSeen].
|
||||
Future<DiscussionSeen> deleteRow(
|
||||
_is.DatabaseSession session,
|
||||
DiscussionSeen row, {
|
||||
_is.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.deleteRow<DiscussionSeen>(
|
||||
row,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes all rows matching the [where] expression.
|
||||
///
|
||||
/// To specify the order of the returned rows use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the deleted rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<DiscussionSeen>> deleteWhere(
|
||||
_is.DatabaseSession session, {
|
||||
required _is.WhereExpressionBuilder<DiscussionSeenTable> where,
|
||||
_is.OrderByBuilder<DiscussionSeenTable>? orderBy,
|
||||
_is.OrderByListBuilder<DiscussionSeenTable>? orderByList,
|
||||
_is.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.deleteWhere<DiscussionSeen>(
|
||||
where: where(DiscussionSeen.t),
|
||||
orderBy: orderBy?.call(DiscussionSeen.t),
|
||||
orderByList: orderByList?.call(DiscussionSeen.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Counts the number of rows matching the [where] expression. If omitted,
|
||||
/// will return the count of all rows in the table.
|
||||
Future<int> count(
|
||||
_is.DatabaseSession session, {
|
||||
_is.WhereExpressionBuilder<DiscussionSeenTable>? where,
|
||||
int? limit,
|
||||
_is.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.count<DiscussionSeen>(
|
||||
where: where?.call(DiscussionSeen.t),
|
||||
limit: limit,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Acquires row-level locks on [DiscussionSeen] rows matching the [where] expression.
|
||||
Future<void> lockRows(
|
||||
_is.DatabaseSession session, {
|
||||
required _is.WhereExpressionBuilder<DiscussionSeenTable> where,
|
||||
required _is.LockMode lockMode,
|
||||
required _is.Transaction transaction,
|
||||
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
|
||||
}) async {
|
||||
return session.db.lockRows<DiscussionSeen>(
|
||||
where: where(DiscussionSeen.t),
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart' as _i5lsenq5;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import '../discussion/discussion_target.dart' as _iqswgn91;
|
||||
import '../feedback/feedback_person.dart' as _i4q3oto5;
|
||||
|
||||
/// A question as a list shows it.
|
||||
abstract class DiscussionSummary
|
||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||
DiscussionSummary._({
|
||||
required this.questionId,
|
||||
required this.publicId,
|
||||
required this.studysetTitle,
|
||||
required this.title,
|
||||
this.asker,
|
||||
required this.askerIsAuthor,
|
||||
required this.target,
|
||||
required this.createdAt,
|
||||
required this.activeAt,
|
||||
required this.answers,
|
||||
required this.accepted,
|
||||
required this.deleted,
|
||||
required this.hidden,
|
||||
required this.mine,
|
||||
required this.unseen,
|
||||
});
|
||||
|
||||
factory DiscussionSummary({
|
||||
required _is.UuidValue questionId,
|
||||
required _is.UuidValue publicId,
|
||||
required String studysetTitle,
|
||||
required String title,
|
||||
_i4q3oto5.FeedbackPerson? asker,
|
||||
required bool askerIsAuthor,
|
||||
required _iqswgn91.DiscussionTarget target,
|
||||
required DateTime createdAt,
|
||||
required DateTime activeAt,
|
||||
required int answers,
|
||||
required bool accepted,
|
||||
required bool deleted,
|
||||
required bool hidden,
|
||||
required bool mine,
|
||||
required bool unseen,
|
||||
}) = _DiscussionSummaryImpl;
|
||||
|
||||
factory DiscussionSummary.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return DiscussionSummary(
|
||||
questionId: _is.UuidValueJsonExtension.fromJson(
|
||||
jsonSerialization['questionId'],
|
||||
),
|
||||
publicId: _is.UuidValueJsonExtension.fromJson(
|
||||
jsonSerialization['publicId'],
|
||||
),
|
||||
studysetTitle: jsonSerialization['studysetTitle'] as String,
|
||||
title: jsonSerialization['title'] as String,
|
||||
asker: jsonSerialization['asker'] == null
|
||||
? null
|
||||
: _i5lsenq5.Protocol().deserialize<_i4q3oto5.FeedbackPerson>(
|
||||
jsonSerialization['asker'],
|
||||
),
|
||||
askerIsAuthor: _is.BoolJsonExtension.fromJson(
|
||||
jsonSerialization['askerIsAuthor'],
|
||||
),
|
||||
target: _i5lsenq5.Protocol().deserialize<_iqswgn91.DiscussionTarget>(
|
||||
jsonSerialization['target'],
|
||||
),
|
||||
createdAt: _is.DateTimeJsonExtension.fromJson(
|
||||
jsonSerialization['createdAt'],
|
||||
),
|
||||
activeAt: _is.DateTimeJsonExtension.fromJson(
|
||||
jsonSerialization['activeAt'],
|
||||
),
|
||||
answers: jsonSerialization['answers'] as int,
|
||||
accepted: _is.BoolJsonExtension.fromJson(jsonSerialization['accepted']),
|
||||
deleted: _is.BoolJsonExtension.fromJson(jsonSerialization['deleted']),
|
||||
hidden: _is.BoolJsonExtension.fromJson(jsonSerialization['hidden']),
|
||||
mine: _is.BoolJsonExtension.fromJson(jsonSerialization['mine']),
|
||||
unseen: _is.BoolJsonExtension.fromJson(jsonSerialization['unseen']),
|
||||
);
|
||||
}
|
||||
|
||||
_is.UuidValue questionId;
|
||||
|
||||
/// The published studyset it is on, and its title.
|
||||
_is.UuidValue publicId;
|
||||
|
||||
String studysetTitle;
|
||||
|
||||
/// Its title; empty once deleted.
|
||||
String title;
|
||||
|
||||
/// Who asked it; null once deleted, or without a public profile.
|
||||
_i4q3oto5.FeedbackPerson? asker;
|
||||
|
||||
bool askerIsAuthor;
|
||||
|
||||
/// What it is about.
|
||||
_iqswgn91.DiscussionTarget target;
|
||||
|
||||
/// When it was asked, and when anything last happened in it, in UTC.
|
||||
DateTime createdAt;
|
||||
|
||||
DateTime activeAt;
|
||||
|
||||
/// How many answers the viewer sees, and whether one is accepted.
|
||||
int answers;
|
||||
|
||||
bool accepted;
|
||||
|
||||
bool deleted;
|
||||
|
||||
bool hidden;
|
||||
|
||||
/// Whether the viewer asked it.
|
||||
bool mine;
|
||||
|
||||
/// Whether others wrote in it since the viewer last read it, where it is
|
||||
/// theirs to follow: they asked, answered or commented, or it is on their
|
||||
/// studyset.
|
||||
bool unseen;
|
||||
|
||||
/// Returns a shallow copy of this [DiscussionSummary]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
DiscussionSummary copyWith({
|
||||
_is.UuidValue? questionId,
|
||||
_is.UuidValue? publicId,
|
||||
String? studysetTitle,
|
||||
String? title,
|
||||
_i4q3oto5.FeedbackPerson? asker,
|
||||
bool? askerIsAuthor,
|
||||
_iqswgn91.DiscussionTarget? target,
|
||||
DateTime? createdAt,
|
||||
DateTime? activeAt,
|
||||
int? answers,
|
||||
bool? accepted,
|
||||
bool? deleted,
|
||||
bool? hidden,
|
||||
bool? mine,
|
||||
bool? unseen,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'DiscussionSummary',
|
||||
'questionId': questionId.toJson(),
|
||||
'publicId': publicId.toJson(),
|
||||
'studysetTitle': studysetTitle,
|
||||
'title': title,
|
||||
if (asker != null) 'asker': asker?.toJson(),
|
||||
'askerIsAuthor': askerIsAuthor,
|
||||
'target': target.toJson(),
|
||||
'createdAt': createdAt.toJson(),
|
||||
'activeAt': activeAt.toJson(),
|
||||
'answers': answers,
|
||||
'accepted': accepted,
|
||||
'deleted': deleted,
|
||||
'hidden': hidden,
|
||||
'mine': mine,
|
||||
'unseen': unseen,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'DiscussionSummary',
|
||||
'questionId': questionId.toJson(),
|
||||
'publicId': publicId.toJson(),
|
||||
'studysetTitle': studysetTitle,
|
||||
'title': title,
|
||||
if (asker != null) 'asker': asker?.toJsonForProtocol(),
|
||||
'askerIsAuthor': askerIsAuthor,
|
||||
'target': target.toJsonForProtocol(),
|
||||
'createdAt': createdAt.toJson(),
|
||||
'activeAt': activeAt.toJson(),
|
||||
'answers': answers,
|
||||
'accepted': accepted,
|
||||
'deleted': deleted,
|
||||
'hidden': hidden,
|
||||
'mine': mine,
|
||||
'unseen': unseen,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _DiscussionSummaryImpl extends DiscussionSummary {
|
||||
_DiscussionSummaryImpl({
|
||||
required _is.UuidValue questionId,
|
||||
required _is.UuidValue publicId,
|
||||
required String studysetTitle,
|
||||
required String title,
|
||||
_i4q3oto5.FeedbackPerson? asker,
|
||||
required bool askerIsAuthor,
|
||||
required _iqswgn91.DiscussionTarget target,
|
||||
required DateTime createdAt,
|
||||
required DateTime activeAt,
|
||||
required int answers,
|
||||
required bool accepted,
|
||||
required bool deleted,
|
||||
required bool hidden,
|
||||
required bool mine,
|
||||
required bool unseen,
|
||||
}) : super._(
|
||||
questionId: questionId,
|
||||
publicId: publicId,
|
||||
studysetTitle: studysetTitle,
|
||||
title: title,
|
||||
asker: asker,
|
||||
askerIsAuthor: askerIsAuthor,
|
||||
target: target,
|
||||
createdAt: createdAt,
|
||||
activeAt: activeAt,
|
||||
answers: answers,
|
||||
accepted: accepted,
|
||||
deleted: deleted,
|
||||
hidden: hidden,
|
||||
mine: mine,
|
||||
unseen: unseen,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [DiscussionSummary]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
DiscussionSummary copyWith({
|
||||
_is.UuidValue? questionId,
|
||||
_is.UuidValue? publicId,
|
||||
String? studysetTitle,
|
||||
String? title,
|
||||
Object? asker = _Undefined,
|
||||
bool? askerIsAuthor,
|
||||
_iqswgn91.DiscussionTarget? target,
|
||||
DateTime? createdAt,
|
||||
DateTime? activeAt,
|
||||
int? answers,
|
||||
bool? accepted,
|
||||
bool? deleted,
|
||||
bool? hidden,
|
||||
bool? mine,
|
||||
bool? unseen,
|
||||
}) {
|
||||
return DiscussionSummary(
|
||||
questionId: questionId ?? this.questionId,
|
||||
publicId: publicId ?? this.publicId,
|
||||
studysetTitle: studysetTitle ?? this.studysetTitle,
|
||||
title: title ?? this.title,
|
||||
asker: asker is _i4q3oto5.FeedbackPerson?
|
||||
? asker
|
||||
: this.asker?.copyWith(),
|
||||
askerIsAuthor: askerIsAuthor ?? this.askerIsAuthor,
|
||||
target: target ?? this.target.copyWith(),
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
activeAt: activeAt ?? this.activeAt,
|
||||
answers: answers ?? this.answers,
|
||||
accepted: accepted ?? this.accepted,
|
||||
deleted: deleted ?? this.deleted,
|
||||
hidden: hidden ?? this.hidden,
|
||||
mine: mine ?? this.mine,
|
||||
unseen: unseen ?? this.unseen,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
|
||||
/// What a question is about: the published studyset or a topic in it
|
||||
/// ('studyset'), or one of its cards, notes or steps ('card', 'note',
|
||||
/// 'step'), by its id in release [version].
|
||||
abstract class DiscussionTarget
|
||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||
DiscussionTarget._({
|
||||
required this.kind,
|
||||
required this.item,
|
||||
required this.version,
|
||||
});
|
||||
|
||||
factory DiscussionTarget({
|
||||
required String kind,
|
||||
required String item,
|
||||
required int version,
|
||||
}) = _DiscussionTargetImpl;
|
||||
|
||||
factory DiscussionTarget.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return DiscussionTarget(
|
||||
kind: jsonSerialization['kind'] as String,
|
||||
item: jsonSerialization['item'] as String,
|
||||
version: jsonSerialization['version'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
String kind;
|
||||
|
||||
String item;
|
||||
|
||||
int version;
|
||||
|
||||
/// Returns a shallow copy of this [DiscussionTarget]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
DiscussionTarget copyWith({
|
||||
String? kind,
|
||||
String? item,
|
||||
int? version,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'DiscussionTarget',
|
||||
'kind': kind,
|
||||
'item': item,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'DiscussionTarget',
|
||||
'kind': kind,
|
||||
'item': item,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiscussionTargetImpl extends DiscussionTarget {
|
||||
_DiscussionTargetImpl({
|
||||
required String kind,
|
||||
required String item,
|
||||
required int version,
|
||||
}) : super._(
|
||||
kind: kind,
|
||||
item: item,
|
||||
version: version,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [DiscussionTarget]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
DiscussionTarget copyWith({
|
||||
String? kind,
|
||||
String? item,
|
||||
int? version,
|
||||
}) {
|
||||
return DiscussionTarget(
|
||||
kind: kind ?? this.kind,
|
||||
item: item ?? this.item,
|
||||
version: version ?? this.version,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue