feat(base)!: removing a profile makes it private (ADR 0020, D27)
Every account keeps a handle now, so removing a public profile no longer deletes it and frees the handle: it takes away what the profile shows and leaves it private, as a profile is until its person goes public (D5). - `profile.remove` and the register's `removeProfile` (members ask the register) empty the display name, bio, links with their checks and the avatar, keep the row with its handle, and return what is left, null for none. `/@handle` and `profiles.byHandle` then answer as for a handle nobody has. Once the person has published, removing is refused as before. - Learn: the profile form starts afresh from what is left, "Remove profile" shows only while something is filled in, and the dialog says the handle stays. - The module's README and ADR 0020 amended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
630965d920
commit
d5651c5beb
11 changed files with 255 additions and 60 deletions
|
|
@ -835,14 +835,17 @@ class EndpointProfile extends _isc.EndpointRef {
|
|||
{},
|
||||
);
|
||||
|
||||
/// Removes your public profile, freeing its handle; nothing when you have
|
||||
/// none. Throws [ProfileRefusedException] once your handle is fixed:
|
||||
/// others refer to it.
|
||||
_ida.Future<void> remove() => caller.callServerEndpoint<void>(
|
||||
'halkyon_base.profile',
|
||||
'remove',
|
||||
{},
|
||||
);
|
||||
/// Removes what your public profile shows (display name, bio, links,
|
||||
/// avatar), making it private again: the handle stays yours (Halkyon
|
||||
/// Learn's ADR 0020, D27). Returns it; null when you have none. Throws
|
||||
/// [ProfileRefusedException] once your handle is fixed: others refer to
|
||||
/// it.
|
||||
_ida.Future<_i8qdmv6n.Profile?> remove() =>
|
||||
caller.callServerEndpoint<_i8qdmv6n.Profile?>(
|
||||
'halkyon_base.profile',
|
||||
'remove',
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/// The public profiles of others, found by their handles. Any signed-in
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
| `emailCode` | `sendCode`: a code by mail, which proves the address and signs nobody in | anyone |
|
||||
| `account` | `delete` (with the password's proof where the account has one, `keysProof` for one that signs in with SimpleLogin), `signInWays` | signed in |
|
||||
| `instance` | `basics`: the app it serves, GitHub's client ID if any and when GitHub sign-in ends, whether codes by mail, password sign-in and SimpleLogin are on, the source link, the oldest app build, where data and backups are | anyone |
|
||||
| `profile` | `mine`, `save`, `checkHandle` (with every answer about a new handle, at most 30 an hour per person and 60 per network), `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove`: the caller's own public profile | `identified` |
|
||||
| `profile` | `mine`, `save`, `checkHandle` (with every answer about a new handle, at most 30 an hour per person and 60 per network), `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove` (what it shows goes, the handle stays, private again: Halkyon Learn's ADR 0020, D27): the caller's own public profile | `identified` |
|
||||
| `profiles` | `byHandle`: anyone's public profile once they went public, whatever the handle's case | signed in |
|
||||
|
||||
- GitHub keeps only the numeric GitHub ID, in Serverpod's own table; email keeps only
|
||||
|
|
|
|||
|
|
@ -82,10 +82,12 @@ class ProfileEndpoint extends Endpoint {
|
|||
Future<Profile> removeAvatar(Session session) =>
|
||||
_register(session).removeAvatar(session, _caller(session));
|
||||
|
||||
/// Removes your public profile, freeing its handle; nothing when you have
|
||||
/// none. Throws [ProfileRefusedException] once your handle is fixed:
|
||||
/// others refer to it.
|
||||
Future<void> remove(Session session) =>
|
||||
/// Removes what your public profile shows (display name, bio, links,
|
||||
/// avatar), making it private again: the handle stays yours (Halkyon
|
||||
/// Learn's ADR 0020, D27). Returns it; null when you have none. Throws
|
||||
/// [ProfileRefusedException] once your handle is fixed: others refer to
|
||||
/// it.
|
||||
Future<Profile?> remove(Session session) =>
|
||||
_register(session).removeProfile(session, _caller(session));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,9 +102,11 @@ abstract interface class AccountRegister {
|
|||
/// `ProfileNotFoundException` while they have no profile.
|
||||
Future<Profile> removeAvatar(Session session, UuidValue person);
|
||||
|
||||
/// Removes [person]'s public profile, freeing its handle; nothing when they
|
||||
/// have none. Throws `ProfileRefusedException` once their handle is fixed.
|
||||
Future<void> removeProfile(Session session, UuidValue person);
|
||||
/// Removes what [person]'s public profile shows (display name, bio, links,
|
||||
/// avatar), making it private again, the handle kept theirs (Halkyon
|
||||
/// Learn's ADR 0020, D27); returns it, null when they have none. Throws
|
||||
/// `ProfileRefusedException` once their handle is fixed.
|
||||
Future<Profile?> removeProfile(Session session, UuidValue person);
|
||||
|
||||
/// Fixes [person]'s handle, as others now refer to it (Halkyon Learn: their
|
||||
/// first publish); nothing when it is fixed already or they have no
|
||||
|
|
|
|||
|
|
@ -166,8 +166,11 @@ Future<Map<String, Object?>?> _answer(
|
|||
);
|
||||
return {'profile': profile.toJson()};
|
||||
case RegisterOperation.removeProfile:
|
||||
await register.removeProfile(session, arguments.id('person'));
|
||||
return const {};
|
||||
final profile = await register.removeProfile(
|
||||
session,
|
||||
arguments.id('person'),
|
||||
);
|
||||
return {'profile': profile?.toJson()};
|
||||
case RegisterOperation.fixHandle:
|
||||
await register.fixHandle(session, arguments.id('person'));
|
||||
return const {};
|
||||
|
|
|
|||
|
|
@ -318,22 +318,37 @@ final class LocalRegister implements AccountRegister {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> removeProfile(Session session, UuidValue person) async {
|
||||
Future<Profile?> removeProfile(Session session, UuidValue person) async {
|
||||
final mine = await _rowOf(session, person);
|
||||
if (mine == null) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
if (mine.firstPublishedAt != null) {
|
||||
throw ProfileRefusedException(reason: ProfileRefusal.published);
|
||||
}
|
||||
// DELETE FROM "public_profile" WHERE "authUserId" = $1
|
||||
await PublicProfile.db.deleteWhere(
|
||||
// UPDATE "public_profile" SET "displayName" = '', "bio" = '',
|
||||
// "links" = '[]', "verifiedLinks" = NULL, "avatarHash" = NULL,
|
||||
// "updatedAt" = $1 WHERE "id" = $2
|
||||
final emptied = await PublicProfile.db.updateRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
mine.copyWith(
|
||||
displayName: '',
|
||||
bio: '',
|
||||
links: const [],
|
||||
// Not the default: the generated copyWith tells an explicit null
|
||||
// (clear it) from no argument (keep it), which the lint can't see.
|
||||
// ignore: avoid_redundant_argument_values
|
||||
verifiedLinks: null,
|
||||
// As above.
|
||||
// ignore: avoid_redundant_argument_values
|
||||
avatarHash: null,
|
||||
updatedAt: clock.now().toUtc(),
|
||||
),
|
||||
);
|
||||
if (mine.avatarHash case final hash?) {
|
||||
await forgetAvatar(session, hash);
|
||||
}
|
||||
return await profileViewOf(session, emptied);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -222,8 +222,12 @@ final class RemoteRegister implements AccountRegister {
|
|||
)!;
|
||||
|
||||
@override
|
||||
Future<void> removeProfile(Session session, UuidValue person) =>
|
||||
_ask(RegisterOperation.removeProfile, {'person': person.uuid});
|
||||
Future<Profile?> removeProfile(Session session, UuidValue person) async =>
|
||||
_profile(
|
||||
(await _ask(RegisterOperation.removeProfile, {
|
||||
'person': person.uuid,
|
||||
}))['profile'],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> fixHandle(Session session, UuidValue person) =>
|
||||
|
|
|
|||
|
|
@ -214,13 +214,58 @@ void main() {
|
|||
expect((renamed.handle, renamed.handleFixed), ('Alice_JP', true));
|
||||
});
|
||||
|
||||
test('removing a profile frees its handle', () async {
|
||||
await save(alice, 'alice');
|
||||
test('removing a profile makes it private: its handle stays its '
|
||||
"person's, what was filled in goes, and others find it as a handle "
|
||||
'nobody has (ADR 0020, D27)', () async {
|
||||
await save(
|
||||
alice,
|
||||
'Alice',
|
||||
displayName: 'Alice',
|
||||
bio: 'Learning Japanese.',
|
||||
links: ['https://halkyon.app'],
|
||||
);
|
||||
expect(
|
||||
(await endpoints.profiles.byHandle(bob, 'alice')).handle,
|
||||
'Alice',
|
||||
);
|
||||
|
||||
await endpoints.profile.remove(alice);
|
||||
final removed = (await endpoints.profile.remove(alice))!;
|
||||
|
||||
expect(await endpoints.profile.mine(alice), isNull);
|
||||
expect((await save(bob, 'alice')).handle, 'alice');
|
||||
expect(
|
||||
(
|
||||
removed.handle,
|
||||
removed.displayName,
|
||||
removed.bio,
|
||||
removed.avatarUrl,
|
||||
removed.handleFixed,
|
||||
),
|
||||
('Alice', '', '', null, false),
|
||||
);
|
||||
expect(removed.links, isEmpty);
|
||||
expect(removed.verifiedLinks ?? const <String>[], isEmpty);
|
||||
expect(
|
||||
(await endpoints.profile.mine(alice))!.toJson(),
|
||||
removed.toJson(),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.profiles.byHandle(bob, 'alice'),
|
||||
throwsA(isA<ProfileNotFoundException>()),
|
||||
);
|
||||
await expectLater(
|
||||
save(bob, 'alice'),
|
||||
refused(ProfileRefusal.handleTaken),
|
||||
);
|
||||
// Filled in again, it is public again.
|
||||
await save(alice, 'Alice', displayName: 'Alice');
|
||||
expect(
|
||||
(await endpoints.profiles.byHandle(bob, 'alice')).displayName,
|
||||
'Alice',
|
||||
);
|
||||
});
|
||||
|
||||
test('removing nothing gives nothing: without a profile', () async {
|
||||
expect(await endpoints.profile.remove(bob), isNull);
|
||||
expect(await endpoints.profile.mine(bob), isNull);
|
||||
});
|
||||
|
||||
test('a profile is found by its handle only once its person goes '
|
||||
|
|
|
|||
|
|
@ -181,8 +181,23 @@ final class _Register implements AccountRegister {
|
|||
_answer('removeAvatar', () => profiles[person]!);
|
||||
|
||||
@override
|
||||
Future<void> removeProfile(Session session, UuidValue person) =>
|
||||
_answer('removeProfile', () => profiles.remove(person));
|
||||
Future<Profile?> removeProfile(Session session, UuidValue person) =>
|
||||
_answer('removeProfile', () {
|
||||
final profile = profiles[person];
|
||||
if (profile == null) {
|
||||
return null;
|
||||
}
|
||||
// Private again, the handle kept (ADR 0020, D27).
|
||||
return profiles[person] = Profile(
|
||||
handle: profile.handle,
|
||||
address: profile.address,
|
||||
displayName: '',
|
||||
bio: '',
|
||||
links: const [],
|
||||
pageUrl: profile.pageUrl,
|
||||
handleFixed: profile.handleFixed,
|
||||
);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> fixHandle(Session session, UuidValue person) =>
|
||||
|
|
@ -1710,6 +1725,17 @@ void main() {
|
|||
expect(await PublicProfile.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('are removed at the register, which says what is left: the '
|
||||
'handle (ADR 0020, D27)', () async {
|
||||
await endpoints.profile.save(asBen(), 'Ben', 'Ben', '', const []);
|
||||
|
||||
final removed = (await endpoints.profile.remove(asBen()))!;
|
||||
|
||||
expect((removed.handle, removed.displayName), ('Ben', ''));
|
||||
expect(register.asked.last, 'removeProfile');
|
||||
expect(register.profiles[ben]!.toJson(), removed.toJson());
|
||||
});
|
||||
|
||||
test('have their handles checked at the register, the device '
|
||||
'network passed on to be counted there', () async {
|
||||
register.handleRefusal = ProfileRefusal.tooManyHandleChecks;
|
||||
|
|
|
|||
|
|
@ -318,6 +318,22 @@ void main() {
|
|||
refused(ProfileRefusal.handleTaken),
|
||||
);
|
||||
|
||||
await remote.saveProfile(
|
||||
session,
|
||||
anna,
|
||||
handle: 'Anna',
|
||||
displayName: 'Anna',
|
||||
bio: '',
|
||||
links: const [],
|
||||
);
|
||||
final removed = (await remote.removeProfile(session, anna))!;
|
||||
expect(
|
||||
(removed.handle, removed.displayName),
|
||||
('Anna', ''),
|
||||
reason: 'removing keeps the handle, private (ADR 0020, D27)',
|
||||
);
|
||||
expect(await remote.removeProfile(session, ben), isNull);
|
||||
|
||||
await remote.fixHandle(session, anna);
|
||||
|
||||
expect((await remote.profileOf(session, anna))!.handleFixed, isTrue);
|
||||
|
|
|
|||
|
|
@ -1720,7 +1720,9 @@ class _ProfileEndpoint {
|
|||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> remove(_ist.TestSessionBuilder sessionBuilder) async {
|
||||
_ida.Future<_it4tom37.Profile?> remove(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
|
|
@ -1740,7 +1742,7 @@ class _ProfileEndpoint {
|
|||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
as _ida.Future<_it4tom37.Profile?>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
|
|
|
|||
Loading…
Reference in a new issue