feat(base,learn): suggest changes within a shared studyset

ADR 0044's step 6b (D44, D46 to D48): a Viewer, and an agent, suggest
changes within a shared studyset, which its Owner and Editors accept or
decline.

A suggestion or report is written as Explore's are, in its writer's own
synced space, with kinds of its own (space-suggestion, space-report)
and the space's id in feedback_thread's publicationId, so no synced
table changes and older apps, skipping kinds they don't know, keep
syncing. Where the studyset is private it is sealed with its key, bound
to the space; a reply to it is sealed for its thread.

Learn's server gathers a space's threads for its members alone
(feedback.threadsInSpace, NotFound for anyone else): only threads and
replies by members now, decisions only from rows in the space itself,
where the engine lets its Owner and Editors alone write; sealed
payloads pass in base64 with their key and nonce. The inbox adds the
threads its caller decides on or wrote. The module tells a host a
person's spaces and each space's members (sharedSpacesOf,
membersOfSpaces). No migration: no table changes.

In the app, a Viewer's card and note offer Suggest a change: the fields
write into the suggestion alone, sent with a message. A shared
studyset's menu lists its Suggestions, where its Owner and Editors
accept a change as a revision, or a new card or note into the
studyset, and decide in the space. A suggestion naming a file that
isn't the studyset's, or bringing files of its own, isn't taken.

Whats-New: In a studyset shared with you as a viewer, suggest a change to a card or note; its owner and editors find your suggestions under Suggestions in its menu and accept or decline them.
Whats-New-De: In einem Lernset, das du nur ansehen darfst, schlägst du Änderungen an Karteikarten und Notizen vor; Besitzer:in und Bearbeiter:innen finden sie im Menü unter „Vorschläge“ und nehmen sie an oder lehnen sie ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 20:34:06 +01:00
commit 82c777b1a5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 2949 additions and 427 deletions

View file

@ -0,0 +1,379 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/shared_space/sync_space_grants.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:test/test.dart';
import '../support/auth_fixtures.dart';
import '../support/sync_database.dart';
import '../test_tools/serverpod_test_tools.dart';
/// Suggestions and reports within a shared space (Halkyon's ADR 0044, D44,
/// D46, D47): written in their writers' own spaces, gathered for the
/// space's members alone, its Owner's and Editors' decisions read from the
/// space itself.
void main() {
withServerpod(
'Suggestions in a shared space',
(sessionBuilder, endpoints) {
late Session session;
late UuidValue alice;
late UuidValue bob;
late UuidValue carol;
late UuidValue dave;
late UuidValue space;
final people = <UuidValue>[];
final grants = SyncSpaceGrants();
TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
{identified},
),
);
Future<UuidValue> person(String handle) async {
final id = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
people.add(id);
await saveProfile(session, id, handle: handle);
return id;
}
/// Makes [person] a member of [space] as [role], in the engine too.
Future<void> join(UuidValue person, SharedSpaceRole role) async {
await SharedSpaceMember.db.insertRow(
session,
SharedSpaceMember(
sharedSpaceId: space,
authUserId: person,
role: role,
joinedAt: clock.now().toUtc(),
),
);
await session.db.transaction(
(t) => grants.grant(
session,
space,
person,
writes: role != SharedSpaceRole.viewer,
transaction: t,
),
);
}
setUp(() async {
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
useLearnBase(sessionBuilder);
session = sessionBuilder.build();
alice = await person('alice');
bob = await person('bob');
carol = await person('carol');
dave = await person('dave');
// Alice's space: Bob an Editor, Carol a Viewer; Dave outside.
space = await session.db.transaction(
(t) => grants.create(session, owner: alice, transaction: t),
);
await SharedSpace.db.insertRow(
session,
SharedSpace(id: space, kind: 'studyset', createdAt: clock.now()),
);
await SharedSpaceMember.db.insertRow(
session,
SharedSpaceMember(
sharedSpaceId: space,
authUserId: alice,
role: SharedSpaceRole.owner,
joinedAt: clock.now().toUtc(),
),
);
await join(bob, SharedSpaceRole.editor);
await join(carol, SharedSpaceRole.viewer);
});
tearDown(() async {
for (final person in people) {
await deletePerson(session, person);
}
people.clear();
await session.db.unsafeExecute(
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
parameters: QueryParameters.named({'space': space.uuid}),
);
});
ByteData json(Map<String, Object?> json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
Future<T> as<T>(
UuidValue person,
Future<T> Function(Transaction transaction) write, {
UuidValue? inSpace,
}) => asPerson(session, person, write, space: inSpace);
/// A thread of [kind] by [writer] about [on], in their own space.
Future<UuidValue> thread(
UuidValue writer, {
String kind = 'space-suggestion',
UuidValue? on,
String? keyId,
ByteData? payload,
}) => as(
writer,
(transaction) async => (await FeedbackThread.db.insertRow(
session,
FeedbackThread(
publicationId: on ?? space,
kind: kind,
keyId: keyId,
nonce: keyId == null ? null : ByteData(24),
payload:
payload ??
json({
'schemaVersion': 1,
'body': 'Another reading.',
'target': {'kind': 'card', 'item': 'c1', 'version': 0},
}),
),
transaction: transaction,
)).id!,
);
Future<void> reply(
UuidValue by,
UuidValue threadId,
String body, {
String? keyId,
}) => as(
by,
(transaction) => FeedbackMessage.db.insertRow(
session,
FeedbackMessage(
threadId: threadId,
keyId: keyId,
nonce: keyId == null ? null : ByteData(24),
payload: keyId == null
? json({'schemaVersion': 1, 'body': body})
: ByteData.sublistView(Uint8List.fromList(utf8.encode(body))),
),
transaction: transaction,
),
);
/// A decision by [by], in the shared space or else in their own.
Future<void> decide(
UuidValue by,
UuidValue threadId,
String decision, {
bool inTheSpace = true,
}) => as(
by,
inSpace: inTheSpace ? space : null,
(transaction) => FeedbackDecision.db.insertRow(
session,
FeedbackDecision(threadId: threadId, decision: decision),
transaction: transaction,
),
);
Future<List<FeedbackView>> readBy(UuidValue person) =>
endpoints.feedback.threadsInSpace(signedIn(person), space);
test("a Viewer's suggestion shows to every member, as in the "
'space', () async {
final id = await thread(carol);
for (final member in [alice, bob, carol]) {
final view = (await readBy(member)).single;
expect(view.threadId, id);
expect(view.kind, 'space-suggestion');
expect(view.space, space);
expect(view.publicId, space);
expect(view.writer.handle, 'carol');
expect(view.status, 'open');
expect(
jsonDecode(view.payload),
containsPair('body', 'Another reading.'),
);
expect(view.mine, member == carol);
// The Owner and its Editors decide on it.
expect(view.forMe, member != carol);
}
});
test('someone outside the space reads none of it, as a space that '
"doesn't exist", () async {
await thread(carol);
await expectLater(readBy(dave), throwsA(isA<NotFoundException>()));
await expectLater(
endpoints.feedback.threadsInSpace(
signedIn(alice),
const Uuid().v7obj(),
),
throwsA(isA<NotFoundException>()),
);
await expectLater(
endpoints.feedback.threadsInSpace(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
space,
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
expect(
(await endpoints.inbox.inbox(signedIn(dave))).where(
(view) => view.space == space,
),
isEmpty,
);
});
test("a thread naming the space by someone who isn't in it, or of a "
"published studyset's kind, shows to nobody", () async {
await thread(dave);
await thread(carol, kind: 'suggestion');
await thread(carol, kind: 'report');
expect(await readBy(alice), isEmpty);
expect(await endpoints.inbox.inbox(signedIn(alice)), isEmpty);
});
test("a space's thread never shows among a published studyset's, "
'nor in another space', () async {
await thread(carol);
final other = await session.db.transaction(
(t) => grants.create(session, owner: dave, transaction: t),
);
await thread(carol, on: other);
expect(
await endpoints.feedback.threadsOf(signedIn(alice), space),
isEmpty,
);
expect(await readBy(alice), hasLength(1));
await session.db.unsafeExecute(
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
parameters: QueryParameters.named({'space': other.uuid}),
);
});
test('the Owner and Editors find it in their inbox, new until read; '
'its writer finds theirs; other Viewers none', () async {
await join(dave, SharedSpaceRole.viewer);
final id = await thread(carol);
for (final decider in [alice, bob]) {
final inbox = await endpoints.inbox.inbox(signedIn(decider));
expect(inbox.single.threadId, id);
expect(inbox.single.unseen, isTrue);
expect(await endpoints.inbox.unseen(signedIn(decider)), 1);
}
expect(
(await endpoints.inbox.inbox(signedIn(carol))).single.mine,
isTrue,
);
expect(await endpoints.inbox.inbox(signedIn(dave)), isEmpty);
await endpoints.inbox.markSeen(signedIn(bob), id);
expect(await endpoints.inbox.unseen(signedIn(bob)), 0);
expect(await endpoints.inbox.unseen(signedIn(alice)), 1);
});
test('replies by members show, those of the Owner and Editors as '
"deciders'; one by someone outside never", () async {
final id = await thread(carol);
await reply(bob, id, 'Which reading?');
await reply(carol, id, 'The kun reading.');
await reply(dave, id, 'Spam.');
final messages = (await readBy(alice)).single.messages;
expect([for (final m in messages) m.by.handle], ['bob', 'carol']);
expect([for (final m in messages) m.fromAuthor], [true, false]);
// Bob's reply is new to Carol.
expect(
(await endpoints.inbox.inbox(signedIn(carol))).single.unseen,
isTrue,
);
});
test('a decision counts only from the space itself, where its Owner '
'and Editors write', () async {
final id = await thread(carol);
// Neither the writer's own space nor an Editor's counts.
await decide(carol, id, 'accepted', inTheSpace: false);
await decide(bob, id, 'declined', inTheSpace: false);
expect((await readBy(alice)).single.status, 'open');
await decide(bob, id, 'accepted');
expect((await readBy(carol)).single.status, 'accepted');
});
test('a Viewer writes no decision into the space', () async {
final id = await thread(carol);
await expectLater(
decide(carol, id, 'accepted'),
throwsA(anything),
);
expect((await readBy(alice)).single.status, 'open');
});
test('hidden, it shows to its writer and the deciders alone', () async {
await join(dave, SharedSpaceRole.viewer);
final id = await thread(carol);
await decide(alice, id, 'hidden');
expect(await readBy(dave), isEmpty);
expect((await readBy(carol)).single.hidden, isTrue);
expect((await readBy(bob)).single.hidden, isTrue);
});
test('a sealed thread and its sealed replies pass as they are, in '
'base64 with their key and nonce', () async {
final sealed = ByteData.sublistView(Uint8List.fromList([1, 2, 3, 4]));
final id = await thread(carol, keyId: 'space-key', payload: sealed);
await reply(bob, id, 'sealed reply', keyId: 'space-key');
final view = (await readBy(alice)).single;
expect(view.keyId, 'space-key');
expect(base64Decode(view.payload), [1, 2, 3, 4]);
expect(base64Decode(view.nonce!), hasLength(24));
final message = view.messages.single;
expect(message.keyId, 'space-key');
expect(utf8.decode(base64Decode(message.payload)), 'sealed reply');
});
test('a member who goes takes their threads and replies out of '
'view', () async {
final id = await thread(carol);
await reply(bob, id, 'Hm.');
await SharedSpaceMember.db.deleteWhere(
session,
where: (t) =>
t.sharedSpaceId.equals(space) & t.authUserId.equals(bob),
);
expect((await readBy(alice)).single.messages, isEmpty);
await SharedSpaceMember.db.deleteWhere(
session,
where: (t) =>
t.sharedSpaceId.equals(space) & t.authUserId.equals(carol),
);
expect(await readBy(alice), isEmpty);
});
},
databaseInterceptor: syncDatabaseInterceptor,
rollbackDatabase: RollbackDatabase.disabled,
);
}

View file

@ -1586,6 +1586,37 @@ class _FeedbackEndpoint {
}
});
}
_ida.Future<List<_iulci38e.FeedbackView>> threadsInSpace(
_ist.TestSessionBuilder sessionBuilder,
_is.UuidValue space,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'feedback',
method: 'threadsInSpace',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'feedback',
methodName: 'threadsInSpace',
parameters: _ist.testObjectToJson({'space': space}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<List<_iulci38e.FeedbackView>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _InboxEndpoint {