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:
Tizian 「ティツィアーン」 2026-10-15 23:52:40 +02:00
commit 5efa552489
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
33 changed files with 28214 additions and 27 deletions

View file

@ -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,

View 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,
);
}

View 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,
);
}

View file

@ -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

View file

@ -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

View 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,
},
);
}
}

View file

@ -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

View file

@ -0,0 +1,5 @@
### Writing in a discussion was refused; nothing changed.
exception: DiscussionRefusedException
fields:
### Why it was refused.
reason: DiscussionRefusal

View 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('|')}|';
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,
);
}
}

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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,
);
}
}

View file

@ -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,
);
}
}

View file

@ -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