feat: add secure clubs foundation and membership architecture
This commit is contained in:
parent
3ca69f9be4
commit
0d8e26750f
File diff suppressed because it is too large
Load Diff
|
|
@ -27,6 +27,88 @@
|
|||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "clubs",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "archivedAt",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "memberCount",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "members",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "joinedAt",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "members",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "requestedAt",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "members",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "joinedAt",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "members",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"fieldOverrides": []
|
||||
|
|
|
|||
286
firestore.rules
286
firestore.rules
|
|
@ -5,6 +5,13 @@ service cloud.firestore {
|
|||
return request.auth != null;
|
||||
}
|
||||
|
||||
function isRegisteredUser() {
|
||||
return signedIn()
|
||||
&& (!('firebase' in request.auth.token)
|
||||
|| !('sign_in_provider' in request.auth.token.firebase)
|
||||
|| request.auth.token.firebase.sign_in_provider != 'anonymous');
|
||||
}
|
||||
|
||||
function ownsUser(userId) {
|
||||
return signedIn() && request.auth.uid == userId;
|
||||
}
|
||||
|
|
@ -637,6 +644,285 @@ service cloud.firestore {
|
|||
allow delete: if isRouteOwner();
|
||||
}
|
||||
|
||||
// --- CLUB HANDLE RESERVATIONS ---
|
||||
match /club_handles/{slug} {
|
||||
allow list: if false;
|
||||
allow get: if isRegisteredUser();
|
||||
allow create: if isRegisteredUser()
|
||||
&& request.resource.data.slug == slug
|
||||
&& request.resource.data.ownerUid == request.auth.uid
|
||||
&& request.resource.data.clubId is string
|
||||
&& request.resource.data.clubId.size() > 0
|
||||
// Post-write club document invariant
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.slug == slug
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.ownerUid == request.auth.uid
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.memberCount == 1
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.archivedAt == null
|
||||
// Post-write owner membership invariant
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.uid == request.auth.uid
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.clubId == request.resource.data.clubId
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.status == 'active'
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.role == 'owner';
|
||||
allow delete: if false;
|
||||
allow update: if false;
|
||||
}
|
||||
|
||||
// --- CLUBS & MEMBERSHIP ---
|
||||
match /clubs/{clubId} {
|
||||
function getClubDoc() {
|
||||
return get(/databases/$(database)/documents/clubs/$(clubId));
|
||||
}
|
||||
|
||||
function isCallerClubOwner() {
|
||||
return isRegisteredUser() && getClubDoc().data.ownerUid == request.auth.uid;
|
||||
}
|
||||
|
||||
function hasMemberDoc(uid) {
|
||||
return exists(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid));
|
||||
}
|
||||
|
||||
function memberData(uid) {
|
||||
return get(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid)).data;
|
||||
}
|
||||
|
||||
function isActiveMember(uid) {
|
||||
return isRegisteredUser() && hasMemberDoc(uid) && memberData(uid).status == 'active';
|
||||
}
|
||||
|
||||
function isPendingInvite(uid) {
|
||||
return isRegisteredUser() && hasMemberDoc(uid) && memberData(uid).status == 'invited';
|
||||
}
|
||||
|
||||
function isClubAdmin(uid) {
|
||||
return isActiveMember(uid) && (memberData(uid).role == 'admin' || memberData(uid).role == 'owner');
|
||||
}
|
||||
|
||||
function isBanned(uid) {
|
||||
return exists(/databases/$(database)/documents/clubs/$(clubId)/bans/$(uid));
|
||||
}
|
||||
|
||||
function isValidMemberCountIncrement() {
|
||||
let targetUid = ('lastMembershipChangeUid' in request.resource.data)
|
||||
? request.resource.data.lastMembershipChangeUid
|
||||
: request.auth.uid;
|
||||
let memberDocPath = /databases/$(database)/documents/clubs/$(clubId)/members/$(targetUid);
|
||||
return (targetUid == request.auth.uid || isClubAdmin(request.auth.uid))
|
||||
&& request.resource.data.memberCount == resource.data.memberCount + 1
|
||||
&& getAfter(memberDocPath).data.status == 'active'
|
||||
&& (!exists(memberDocPath) || get(memberDocPath).data.status != 'active');
|
||||
}
|
||||
|
||||
function isValidMemberCountDecrement() {
|
||||
let targetUid = ('lastMembershipChangeUid' in request.resource.data)
|
||||
? request.resource.data.lastMembershipChangeUid
|
||||
: request.auth.uid;
|
||||
let memberDocPath = /databases/$(database)/documents/clubs/$(clubId)/members/$(targetUid);
|
||||
return (targetUid == request.auth.uid || isClubAdmin(request.auth.uid))
|
||||
&& request.resource.data.memberCount == resource.data.memberCount - 1
|
||||
&& request.resource.data.memberCount >= 1
|
||||
&& getAfter(memberDocPath).data.status in ['left', 'removed']
|
||||
&& exists(memberDocPath)
|
||||
&& get(memberDocPath).data.status == 'active';
|
||||
}
|
||||
|
||||
// Point-read access to club root:
|
||||
// Active members, owner, or invited riders evaluating an invite may read private clubs.
|
||||
// Unlisted and public clubs can be point-read by any registered rider.
|
||||
allow get: if isRegisteredUser() && (
|
||||
resource.data.visibility in ['public', 'unlisted'] ||
|
||||
resource.data.ownerUid == request.auth.uid ||
|
||||
isActiveMember(request.auth.uid) ||
|
||||
isPendingInvite(request.auth.uid)
|
||||
);
|
||||
|
||||
// Discovery list query: Safe public discovery or owner's owned clubs query only
|
||||
allow list: if isRegisteredUser() && (
|
||||
(resource.data.visibility == 'public' && resource.data.archivedAt == null) ||
|
||||
(resource.data.ownerUid == request.auth.uid && resource.data.archivedAt == null)
|
||||
);
|
||||
|
||||
// Club creation: Atomic batch/transaction creating club + owner membership + slug reservation
|
||||
allow create: if isRegisteredUser()
|
||||
&& request.resource.data.id == clubId
|
||||
&& request.resource.data.ownerUid == request.auth.uid
|
||||
&& request.resource.data.memberCount == 1
|
||||
&& request.resource.data.archivedAt == null
|
||||
&& request.resource.data.slug is string
|
||||
&& request.resource.data.slug.size() >= 3
|
||||
&& request.resource.data.slug.size() <= 30
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.role == 'owner'
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.status == 'active'
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.clubId == clubId
|
||||
&& getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.clubId == clubId
|
||||
&& getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.ownerUid == request.auth.uid
|
||||
&& getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.slug == request.resource.data.slug;
|
||||
|
||||
// Club update: Ownership transfer, metadata edit, or member count change
|
||||
allow update: if isRegisteredUser() && (
|
||||
// Case 1: Owner transferring ownership atomically (cannot mutate memberCount, slug, createdAt)
|
||||
(
|
||||
request.auth.uid == resource.data.ownerUid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['ownerUid', 'updatedAt'])
|
||||
&& request.resource.data.ownerUid != resource.data.ownerUid
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.resource.data.ownerUid)).data.role == 'owner'
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.resource.data.ownerUid)).data.status == 'active'
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(resource.data.ownerUid)).data.role != 'owner'
|
||||
) ||
|
||||
// Case 2: Owner general metadata update or archiving (slug, ownerUid, memberCount, createdAt immutable)
|
||||
(
|
||||
request.auth.uid == resource.data.ownerUid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
||||
'name', 'description', 'visibility', 'admissionPolicy',
|
||||
'logoUrl', 'bannerUrl', 'homeRegion', 'motorcycleFocus',
|
||||
'archivedAt', 'updatedAt'
|
||||
])
|
||||
&& (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50))
|
||||
) ||
|
||||
// Case 3: Admin editing metadata (cannot change ownerUid, slug, createdAt, archivedAt, visibility, admissionPolicy, memberCount)
|
||||
(
|
||||
isClubAdmin(request.auth.uid)
|
||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
||||
'name', 'description', 'logoUrl', 'bannerUrl',
|
||||
'homeRegion', 'motorcycleFocus', 'updatedAt'
|
||||
])
|
||||
&& (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50))
|
||||
) ||
|
||||
// Case 4: Member count increment/decrement by membership transitions ONLY
|
||||
(
|
||||
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['memberCount', 'updatedAt', 'lastMembershipChangeUid'])
|
||||
&& (isValidMemberCountIncrement() || isValidMemberCountDecrement())
|
||||
)
|
||||
);
|
||||
|
||||
allow delete: if false;
|
||||
|
||||
// --- MEMBERS SUBCOLLECTION ---
|
||||
match /members/{memberUid} {
|
||||
// Point read: Caller themselves, club management, or peer active member (if not mutually blocked)
|
||||
allow get: if isRegisteredUser() && (
|
||||
request.auth.uid == memberUid ||
|
||||
isCallerClubOwner() ||
|
||||
isClubAdmin(request.auth.uid) ||
|
||||
(isActiveMember(request.auth.uid) && resource.data.status == 'active' && !areMutuallyBlocked(request.auth.uid, memberUid))
|
||||
);
|
||||
|
||||
// List roster: Active members or club management only
|
||||
allow list: if isRegisteredUser() && (
|
||||
isCallerClubOwner() ||
|
||||
isClubAdmin(request.auth.uid) ||
|
||||
(isActiveMember(request.auth.uid) && resource.data.status == 'active')
|
||||
);
|
||||
|
||||
// Create membership doc: Owner creation, open join, request to join, or admin invitation
|
||||
allow create: if isRegisteredUser() && !isBanned(memberUid) && (
|
||||
// Owner creating self doc during club creation
|
||||
(
|
||||
request.auth.uid == memberUid
|
||||
&& request.resource.data.role == 'owner'
|
||||
&& request.resource.data.status == 'active'
|
||||
&& request.resource.data.clubId == clubId
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid == request.auth.uid
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == 1
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.archivedAt == null
|
||||
&& getAfter(/databases/$(database)/documents/club_handles/$(getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.slug)).data.clubId == clubId
|
||||
&& getAfter(/databases/$(database)/documents/club_handles/$(getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.slug)).data.ownerUid == request.auth.uid
|
||||
) ||
|
||||
// Rider joining open club
|
||||
(
|
||||
request.auth.uid == memberUid
|
||||
&& get(/databases/$(database)/documents/clubs/$(clubId)).data.admissionPolicy == 'open'
|
||||
&& request.resource.data.role == 'member'
|
||||
&& request.resource.data.status == 'active'
|
||||
&& !areMutuallyBlocked(request.auth.uid, get(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid)
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1
|
||||
) ||
|
||||
// Rider requesting to join
|
||||
(
|
||||
request.auth.uid == memberUid
|
||||
&& get(/databases/$(database)/documents/clubs/$(clubId)).data.admissionPolicy == 'request'
|
||||
&& request.resource.data.role == 'member'
|
||||
&& request.resource.data.status == 'requested'
|
||||
&& !areMutuallyBlocked(request.auth.uid, get(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid)
|
||||
) ||
|
||||
// Admin / Owner inviting rider
|
||||
(
|
||||
isClubAdmin(request.auth.uid)
|
||||
&& request.resource.data.status == 'invited'
|
||||
&& request.resource.data.role == 'member'
|
||||
&& !areMutuallyBlocked(request.auth.uid, memberUid)
|
||||
)
|
||||
);
|
||||
|
||||
// Transitions
|
||||
allow update: if isRegisteredUser() && (
|
||||
// Invitee accepts invite (invited -> active)
|
||||
(
|
||||
request.auth.uid == memberUid
|
||||
&& resource.data.status == 'invited'
|
||||
&& request.resource.data.status == 'active'
|
||||
&& request.resource.data.role == resource.data.role
|
||||
&& !isBanned(memberUid)
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1
|
||||
) ||
|
||||
// Invitee declines invite (invited -> declined)
|
||||
(request.auth.uid == memberUid && resource.data.status == 'invited' && request.resource.data.status == 'declined' && request.resource.data.role == resource.data.role) ||
|
||||
// Member leaves (active -> left), non-owner only
|
||||
(
|
||||
request.auth.uid == memberUid
|
||||
&& resource.data.status == 'active'
|
||||
&& request.resource.data.status == 'left'
|
||||
&& resource.data.role != 'owner'
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount - 1
|
||||
) ||
|
||||
// Requestor cancels request (requested -> left)
|
||||
(request.auth.uid == memberUid && resource.data.status == 'requested' && request.resource.data.status == 'left') ||
|
||||
// Admin/Owner approves request (requested -> active)
|
||||
(
|
||||
isClubAdmin(request.auth.uid)
|
||||
&& resource.data.status == 'requested'
|
||||
&& request.resource.data.status == 'active'
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1
|
||||
) ||
|
||||
// Admin/Owner declines request (requested -> declined)
|
||||
(isClubAdmin(request.auth.uid) && resource.data.status == 'requested' && request.resource.data.status == 'declined') ||
|
||||
// Admin/Owner removes active member (active -> removed), hierarchy enforced
|
||||
(
|
||||
isClubAdmin(request.auth.uid)
|
||||
&& resource.data.status == 'active'
|
||||
&& request.resource.data.status == 'removed'
|
||||
&& resource.data.role != 'owner'
|
||||
&& (isCallerClubOwner() || resource.data.role != 'admin')
|
||||
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount - 1
|
||||
) ||
|
||||
// Admin/Owner removes non-active member (requested/invited -> removed)
|
||||
(
|
||||
isClubAdmin(request.auth.uid)
|
||||
&& resource.data.status != 'active'
|
||||
&& request.resource.data.status == 'removed'
|
||||
&& resource.data.role != 'owner'
|
||||
&& (isCallerClubOwner() || resource.data.role != 'admin')
|
||||
) ||
|
||||
// Owner updates role (role update or ownership transfer)
|
||||
(isCallerClubOwner() && request.resource.data.status == resource.data.status) ||
|
||||
// Display rank update by admin/owner
|
||||
(isClubAdmin(request.auth.uid) && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['displayRank', 'updatedAt']))
|
||||
);
|
||||
|
||||
allow delete: if false;
|
||||
}
|
||||
|
||||
// --- ADMINISTRATIVE BANS SUBCOLLECTION ---
|
||||
match /bans/{bannedUid} {
|
||||
allow read, write: if isRegisteredUser() && isClubAdmin(request.auth.uid);
|
||||
}
|
||||
}
|
||||
|
||||
// --- COLLECTION GROUP: MEMBERS ---
|
||||
// Scoped strictly so caller can list only documents belonging to their own UID.
|
||||
match /{path=**}/members/{memberUid} {
|
||||
allow list: if isRegisteredUser() && resource.data.uid == request.auth.uid;
|
||||
}
|
||||
|
||||
match /{document=**} {
|
||||
allow read, write: if false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,607 @@
|
|||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Visibility setting governing discoverability and access to a Club.
|
||||
enum ClubVisibility {
|
||||
public,
|
||||
unlisted,
|
||||
private;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case ClubVisibility.public:
|
||||
return 'Public';
|
||||
case ClubVisibility.unlisted:
|
||||
return 'Unlisted';
|
||||
case ClubVisibility.private:
|
||||
return 'Private';
|
||||
}
|
||||
}
|
||||
|
||||
static ClubVisibility fromString(String? value) {
|
||||
switch (value) {
|
||||
case 'unlisted':
|
||||
return ClubVisibility.unlisted;
|
||||
case 'private':
|
||||
return ClubVisibility.private;
|
||||
case 'public':
|
||||
default:
|
||||
return ClubVisibility.public;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Admission policy governing onboarding into a Club.
|
||||
enum ClubAdmissionPolicy {
|
||||
open,
|
||||
request,
|
||||
inviteOnly;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case ClubAdmissionPolicy.open:
|
||||
return 'Open to Join';
|
||||
case ClubAdmissionPolicy.request:
|
||||
return 'Request to Join';
|
||||
case ClubAdmissionPolicy.inviteOnly:
|
||||
return 'Invite Only';
|
||||
}
|
||||
}
|
||||
|
||||
static ClubAdmissionPolicy fromString(String? value) {
|
||||
switch (value) {
|
||||
case 'open':
|
||||
return ClubAdmissionPolicy.open;
|
||||
case 'inviteOnly':
|
||||
return ClubAdmissionPolicy.inviteOnly;
|
||||
case 'request':
|
||||
default:
|
||||
return ClubAdmissionPolicy.request;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Security permission role within a Club.
|
||||
enum ClubRole {
|
||||
owner,
|
||||
admin,
|
||||
member;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case ClubRole.owner:
|
||||
return 'Owner';
|
||||
case ClubRole.admin:
|
||||
return 'Admin';
|
||||
case ClubRole.member:
|
||||
return 'Member';
|
||||
}
|
||||
}
|
||||
|
||||
bool isAtLeast(ClubRole requiredRole) {
|
||||
if (this == ClubRole.owner) return true;
|
||||
if (this == ClubRole.admin) {
|
||||
return requiredRole == ClubRole.admin || requiredRole == ClubRole.member;
|
||||
}
|
||||
return requiredRole == ClubRole.member;
|
||||
}
|
||||
|
||||
static ClubRole fromString(String? value) {
|
||||
switch (value) {
|
||||
case 'owner':
|
||||
return ClubRole.owner;
|
||||
case 'admin':
|
||||
return ClubRole.admin;
|
||||
case 'member':
|
||||
default:
|
||||
return ClubRole.member;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifecycle status of an individual Club member.
|
||||
enum ClubMemberStatus {
|
||||
invited,
|
||||
requested,
|
||||
active,
|
||||
declined,
|
||||
left,
|
||||
removed;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case ClubMemberStatus.invited:
|
||||
return 'Invited';
|
||||
case ClubMemberStatus.requested:
|
||||
return 'Requested';
|
||||
case ClubMemberStatus.active:
|
||||
return 'Active';
|
||||
case ClubMemberStatus.declined:
|
||||
return 'Declined';
|
||||
case ClubMemberStatus.left:
|
||||
return 'Left';
|
||||
case ClubMemberStatus.removed:
|
||||
return 'Removed';
|
||||
}
|
||||
}
|
||||
|
||||
bool get isActive => this == ClubMemberStatus.active;
|
||||
bool get isPending => this == ClubMemberStatus.invited || this == ClubMemberStatus.requested;
|
||||
bool get isHistorical => this == ClubMemberStatus.declined || this == ClubMemberStatus.left || this == ClubMemberStatus.removed;
|
||||
|
||||
static ClubMemberStatus fromString(String? value) {
|
||||
switch (value) {
|
||||
case 'invited':
|
||||
return ClubMemberStatus.invited;
|
||||
case 'requested':
|
||||
return ClubMemberStatus.requested;
|
||||
case 'active':
|
||||
return ClubMemberStatus.active;
|
||||
case 'declined':
|
||||
return ClubMemberStatus.declined;
|
||||
case 'left':
|
||||
return ClubMemberStatus.left;
|
||||
case 'removed':
|
||||
return ClubMemberStatus.removed;
|
||||
default:
|
||||
return ClubMemberStatus.declined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? _parseDateTime(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is Timestamp) return value.toDate();
|
||||
if (value is DateTime) return value;
|
||||
if (value is String) return DateTime.tryParse(value);
|
||||
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
dynamic _dateTimeToFirestore(DateTime? dt) {
|
||||
if (dt == null) return null;
|
||||
return Timestamp.fromDate(dt);
|
||||
}
|
||||
|
||||
/// Core MotorSignal Club model.
|
||||
@immutable
|
||||
class Club {
|
||||
final String id;
|
||||
final String name;
|
||||
final String slug;
|
||||
final String description;
|
||||
final ClubVisibility visibility;
|
||||
final ClubAdmissionPolicy admissionPolicy;
|
||||
final String ownerUid;
|
||||
final int memberCount;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final DateTime? archivedAt;
|
||||
final String? logoUrl;
|
||||
final String? bannerUrl;
|
||||
final String? homeRegion;
|
||||
final String? motorcycleFocus;
|
||||
|
||||
const Club({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.slug,
|
||||
this.description = '',
|
||||
this.visibility = ClubVisibility.public,
|
||||
this.admissionPolicy = ClubAdmissionPolicy.request,
|
||||
required this.ownerUid,
|
||||
this.memberCount = 1,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.archivedAt,
|
||||
this.logoUrl,
|
||||
this.bannerUrl,
|
||||
this.homeRegion,
|
||||
this.motorcycleFocus,
|
||||
});
|
||||
|
||||
bool get isArchived => archivedAt != null;
|
||||
|
||||
Map<String, dynamic> toFirestore() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'slug': slug,
|
||||
'description': description,
|
||||
'visibility': visibility.name,
|
||||
'admissionPolicy': admissionPolicy.name,
|
||||
'ownerUid': ownerUid,
|
||||
'memberCount': memberCount,
|
||||
'createdAt': _dateTimeToFirestore(createdAt),
|
||||
'updatedAt': _dateTimeToFirestore(updatedAt),
|
||||
if (archivedAt != null) 'archivedAt': _dateTimeToFirestore(archivedAt),
|
||||
if (logoUrl != null) 'logoUrl': logoUrl,
|
||||
if (bannerUrl != null) 'bannerUrl': bannerUrl,
|
||||
if (homeRegion != null) 'homeRegion': homeRegion,
|
||||
if (motorcycleFocus != null) 'motorcycleFocus': motorcycleFocus,
|
||||
};
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'slug': slug,
|
||||
'description': description,
|
||||
'visibility': visibility.name,
|
||||
'admissionPolicy': admissionPolicy.name,
|
||||
'ownerUid': ownerUid,
|
||||
'memberCount': memberCount,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
if (archivedAt != null) 'archivedAt': archivedAt!.toIso8601String(),
|
||||
if (logoUrl != null) 'logoUrl': logoUrl,
|
||||
if (bannerUrl != null) 'bannerUrl': bannerUrl,
|
||||
if (homeRegion != null) 'homeRegion': homeRegion,
|
||||
if (motorcycleFocus != null) 'motorcycleFocus': motorcycleFocus,
|
||||
};
|
||||
|
||||
factory Club.fromFirestore(DocumentSnapshot doc) {
|
||||
final data = doc.data() as Map<String, dynamic>? ?? {};
|
||||
return Club.fromJson(data, idOverride: doc.id);
|
||||
}
|
||||
|
||||
factory Club.fromJson(Map<String, dynamic> json, {String? idOverride}) {
|
||||
final created = _parseDateTime(json['createdAt']) ?? DateTime.now();
|
||||
final updated = _parseDateTime(json['updatedAt']) ?? created;
|
||||
final archived = _parseDateTime(json['archivedAt']);
|
||||
|
||||
return Club(
|
||||
id: idOverride ?? json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
slug: (json['slug'] as String? ?? '').toLowerCase().trim(),
|
||||
description: json['description'] as String? ?? '',
|
||||
visibility: ClubVisibility.fromString(json['visibility'] as String?),
|
||||
admissionPolicy: ClubAdmissionPolicy.fromString(json['admissionPolicy'] as String?),
|
||||
ownerUid: json['ownerUid'] as String? ?? '',
|
||||
memberCount: (json['memberCount'] as num?)?.toInt() ?? 1,
|
||||
createdAt: created,
|
||||
updatedAt: updated,
|
||||
archivedAt: archived,
|
||||
logoUrl: json['logoUrl'] as String?,
|
||||
bannerUrl: json['bannerUrl'] as String?,
|
||||
homeRegion: json['homeRegion'] as String?,
|
||||
motorcycleFocus: json['motorcycleFocus'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Club copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? slug,
|
||||
String? description,
|
||||
ClubVisibility? visibility,
|
||||
ClubAdmissionPolicy? admissionPolicy,
|
||||
String? ownerUid,
|
||||
int? memberCount,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? archivedAt,
|
||||
String? logoUrl,
|
||||
String? bannerUrl,
|
||||
String? homeRegion,
|
||||
String? motorcycleFocus,
|
||||
bool clearArchivedAt = false,
|
||||
}) {
|
||||
return Club(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
slug: slug ?? this.slug,
|
||||
description: description ?? this.description,
|
||||
visibility: visibility ?? this.visibility,
|
||||
admissionPolicy: admissionPolicy ?? this.admissionPolicy,
|
||||
ownerUid: ownerUid ?? this.ownerUid,
|
||||
memberCount: memberCount ?? this.memberCount,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt),
|
||||
logoUrl: logoUrl ?? this.logoUrl,
|
||||
bannerUrl: bannerUrl ?? this.bannerUrl,
|
||||
homeRegion: homeRegion ?? this.homeRegion,
|
||||
motorcycleFocus: motorcycleFocus ?? this.motorcycleFocus,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is Club &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
name == other.name &&
|
||||
slug == other.slug &&
|
||||
description == other.description &&
|
||||
visibility == other.visibility &&
|
||||
admissionPolicy == other.admissionPolicy &&
|
||||
ownerUid == other.ownerUid &&
|
||||
memberCount == other.memberCount &&
|
||||
createdAt == other.createdAt &&
|
||||
updatedAt == other.updatedAt &&
|
||||
archivedAt == other.archivedAt &&
|
||||
logoUrl == other.logoUrl &&
|
||||
bannerUrl == other.bannerUrl &&
|
||||
homeRegion == other.homeRegion &&
|
||||
motorcycleFocus == other.motorcycleFocus;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
name.hashCode ^
|
||||
slug.hashCode ^
|
||||
description.hashCode ^
|
||||
visibility.hashCode ^
|
||||
admissionPolicy.hashCode ^
|
||||
ownerUid.hashCode ^
|
||||
memberCount.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
updatedAt.hashCode ^
|
||||
archivedAt.hashCode ^
|
||||
logoUrl.hashCode ^
|
||||
bannerUrl.hashCode ^
|
||||
homeRegion.hashCode ^
|
||||
motorcycleFocus.hashCode;
|
||||
}
|
||||
|
||||
/// Lean ClubMember document maintaining organizational state.
|
||||
/// Canonical rider identity remains in public_profiles/{uid}.
|
||||
@immutable
|
||||
class ClubMember {
|
||||
final String uid;
|
||||
final String clubId;
|
||||
final ClubMemberStatus status;
|
||||
final ClubRole role;
|
||||
final String? displayRank;
|
||||
final DateTime? joinedAt;
|
||||
final DateTime? requestedAt;
|
||||
final String? invitedByUid;
|
||||
final DateTime updatedAt;
|
||||
final DateTime? removedAt;
|
||||
|
||||
const ClubMember({
|
||||
required this.uid,
|
||||
required this.clubId,
|
||||
required this.status,
|
||||
required this.role,
|
||||
this.displayRank,
|
||||
this.joinedAt,
|
||||
this.requestedAt,
|
||||
this.invitedByUid,
|
||||
required this.updatedAt,
|
||||
this.removedAt,
|
||||
});
|
||||
|
||||
bool get isActive => status == ClubMemberStatus.active;
|
||||
bool get isOwner => role == ClubRole.owner && isActive;
|
||||
bool get isAdmin => (role == ClubRole.admin || role == ClubRole.owner) && isActive;
|
||||
|
||||
Map<String, dynamic> toFirestore() => {
|
||||
'uid': uid,
|
||||
'clubId': clubId,
|
||||
'status': status.name,
|
||||
'role': role.name,
|
||||
if (displayRank != null) 'displayRank': displayRank,
|
||||
if (joinedAt != null) 'joinedAt': _dateTimeToFirestore(joinedAt),
|
||||
if (requestedAt != null) 'requestedAt': _dateTimeToFirestore(requestedAt),
|
||||
if (invitedByUid != null) 'invitedByUid': invitedByUid,
|
||||
'updatedAt': _dateTimeToFirestore(updatedAt),
|
||||
if (removedAt != null) 'removedAt': _dateTimeToFirestore(removedAt),
|
||||
};
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'uid': uid,
|
||||
'clubId': clubId,
|
||||
'status': status.name,
|
||||
'role': role.name,
|
||||
if (displayRank != null) 'displayRank': displayRank,
|
||||
if (joinedAt != null) 'joinedAt': joinedAt!.toIso8601String(),
|
||||
if (requestedAt != null) 'requestedAt': requestedAt!.toIso8601String(),
|
||||
if (invitedByUid != null) 'invitedByUid': invitedByUid,
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
if (removedAt != null) 'removedAt': removedAt!.toIso8601String(),
|
||||
};
|
||||
|
||||
factory ClubMember.fromFirestore(DocumentSnapshot doc) {
|
||||
final data = doc.data() as Map<String, dynamic>? ?? {};
|
||||
return ClubMember.fromJson(data, uidOverride: doc.id);
|
||||
}
|
||||
|
||||
factory ClubMember.fromJson(Map<String, dynamic> json, {String? uidOverride}) {
|
||||
final updated = _parseDateTime(json['updatedAt']) ?? DateTime.now();
|
||||
|
||||
return ClubMember(
|
||||
uid: uidOverride ?? json['uid'] as String? ?? '',
|
||||
clubId: json['clubId'] as String? ?? '',
|
||||
status: ClubMemberStatus.fromString(json['status'] as String?),
|
||||
role: ClubRole.fromString(json['role'] as String?),
|
||||
displayRank: json['displayRank'] as String?,
|
||||
joinedAt: _parseDateTime(json['joinedAt']),
|
||||
requestedAt: _parseDateTime(json['requestedAt']),
|
||||
invitedByUid: json['invitedByUid'] as String?,
|
||||
updatedAt: updated,
|
||||
removedAt: _parseDateTime(json['removedAt']),
|
||||
);
|
||||
}
|
||||
|
||||
ClubMember copyWith({
|
||||
String? uid,
|
||||
String? clubId,
|
||||
ClubMemberStatus? status,
|
||||
ClubRole? role,
|
||||
String? displayRank,
|
||||
DateTime? joinedAt,
|
||||
DateTime? requestedAt,
|
||||
String? invitedByUid,
|
||||
DateTime? updatedAt,
|
||||
DateTime? removedAt,
|
||||
bool clearDisplayRank = false,
|
||||
}) {
|
||||
return ClubMember(
|
||||
uid: uid ?? this.uid,
|
||||
clubId: clubId ?? this.clubId,
|
||||
status: status ?? this.status,
|
||||
role: role ?? this.role,
|
||||
displayRank: clearDisplayRank ? null : (displayRank ?? this.displayRank),
|
||||
joinedAt: joinedAt ?? this.joinedAt,
|
||||
requestedAt: requestedAt ?? this.requestedAt,
|
||||
invitedByUid: invitedByUid ?? this.invitedByUid,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
removedAt: removedAt ?? this.removedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ClubMember &&
|
||||
runtimeType == other.runtimeType &&
|
||||
uid == other.uid &&
|
||||
clubId == other.clubId &&
|
||||
status == other.status &&
|
||||
role == other.role &&
|
||||
displayRank == other.displayRank &&
|
||||
joinedAt == other.joinedAt &&
|
||||
requestedAt == other.requestedAt &&
|
||||
invitedByUid == other.invitedByUid &&
|
||||
updatedAt == other.updatedAt &&
|
||||
removedAt == other.removedAt;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
uid.hashCode ^
|
||||
clubId.hashCode ^
|
||||
status.hashCode ^
|
||||
role.hashCode ^
|
||||
displayRank.hashCode ^
|
||||
joinedAt.hashCode ^
|
||||
requestedAt.hashCode ^
|
||||
invitedByUid.hashCode ^
|
||||
updatedAt.hashCode ^
|
||||
removedAt.hashCode;
|
||||
}
|
||||
|
||||
/// Administrative ban document stored at clubs/{clubId}/bans/{uid}.
|
||||
/// Private to club leadership; never exposed on regular member roster.
|
||||
@immutable
|
||||
class ClubBan {
|
||||
final String uid;
|
||||
final String clubId;
|
||||
final String bannedByUid;
|
||||
final DateTime bannedAt;
|
||||
final String? reason;
|
||||
|
||||
const ClubBan({
|
||||
required this.uid,
|
||||
required this.clubId,
|
||||
required this.bannedByUid,
|
||||
required this.bannedAt,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toFirestore() => {
|
||||
'uid': uid,
|
||||
'clubId': clubId,
|
||||
'bannedByUid': bannedByUid,
|
||||
'bannedAt': _dateTimeToFirestore(bannedAt),
|
||||
if (reason != null) 'reason': reason,
|
||||
};
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'uid': uid,
|
||||
'clubId': clubId,
|
||||
'bannedByUid': bannedByUid,
|
||||
'bannedAt': bannedAt.toIso8601String(),
|
||||
if (reason != null) 'reason': reason,
|
||||
};
|
||||
|
||||
factory ClubBan.fromFirestore(DocumentSnapshot doc) {
|
||||
final data = doc.data() as Map<String, dynamic>? ?? {};
|
||||
return ClubBan.fromJson(data, uidOverride: doc.id);
|
||||
}
|
||||
|
||||
factory ClubBan.fromJson(Map<String, dynamic> json, {String? uidOverride}) {
|
||||
return ClubBan(
|
||||
uid: uidOverride ?? json['uid'] as String? ?? '',
|
||||
clubId: json['clubId'] as String? ?? '',
|
||||
bannedByUid: json['bannedByUid'] as String? ?? '',
|
||||
bannedAt: _parseDateTime(json['bannedAt']) ?? DateTime.now(),
|
||||
reason: json['reason'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ClubBan &&
|
||||
runtimeType == other.runtimeType &&
|
||||
uid == other.uid &&
|
||||
clubId == other.clubId &&
|
||||
bannedByUid == other.bannedByUid &&
|
||||
bannedAt == other.bannedAt &&
|
||||
reason == other.reason;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
uid.hashCode ^
|
||||
clubId.hashCode ^
|
||||
bannedByUid.hashCode ^
|
||||
bannedAt.hashCode ^
|
||||
reason.hashCode;
|
||||
}
|
||||
|
||||
/// Global slug reservation document at club_handles/{slug}.
|
||||
@immutable
|
||||
class ClubHandle {
|
||||
final String slug;
|
||||
final String clubId;
|
||||
final String ownerUid;
|
||||
final DateTime createdAt;
|
||||
|
||||
const ClubHandle({
|
||||
required this.slug,
|
||||
required this.clubId,
|
||||
required this.ownerUid,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toFirestore() => {
|
||||
'slug': slug,
|
||||
'clubId': clubId,
|
||||
'ownerUid': ownerUid,
|
||||
'createdAt': _dateTimeToFirestore(createdAt),
|
||||
};
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'slug': slug,
|
||||
'clubId': clubId,
|
||||
'ownerUid': ownerUid,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory ClubHandle.fromJson(Map<String, dynamic> json, {String? slugOverride}) {
|
||||
return ClubHandle(
|
||||
slug: slugOverride ?? json['slug'] as String? ?? '',
|
||||
clubId: json['clubId'] as String? ?? '',
|
||||
ownerUid: json['ownerUid'] as String? ?? '',
|
||||
createdAt: _parseDateTime(json['createdAt']) ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ClubHandle &&
|
||||
runtimeType == other.runtimeType &&
|
||||
slug == other.slug &&
|
||||
clubId == other.clubId &&
|
||||
ownerUid == other.ownerUid &&
|
||||
createdAt == other.createdAt;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
slug.hashCode ^
|
||||
clubId.hashCode ^
|
||||
ownerUid.hashCode ^
|
||||
createdAt.hashCode;
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../auth/auth_providers.dart';
|
||||
import 'club_models.dart';
|
||||
import 'club_service.dart';
|
||||
|
||||
/// Provider for ClubService.
|
||||
final clubServiceProvider = Provider<ClubService>((ref) {
|
||||
return ClubService();
|
||||
});
|
||||
|
||||
/// Stream of the active user's active Club memberships.
|
||||
/// Gated to registered, non-anonymous users.
|
||||
final myClubsProvider = StreamProvider<List<ClubMember>>((ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null || user.isAnonymous) {
|
||||
return Stream.value(const []);
|
||||
}
|
||||
final service = ref.watch(clubServiceProvider);
|
||||
return service.myActiveClubsStream(user.uid);
|
||||
});
|
||||
|
||||
/// Stream of incoming club invitations for the active user.
|
||||
/// Gated to registered, non-anonymous users.
|
||||
final myClubInvitesProvider = StreamProvider<List<ClubMember>>((ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null || user.isAnonymous) {
|
||||
return Stream.value(const []);
|
||||
}
|
||||
final service = ref.watch(clubServiceProvider);
|
||||
return service.myInvitationsStream(user.uid);
|
||||
});
|
||||
|
||||
/// Realtime stream of a specific Club's root document.
|
||||
final clubDetailProvider = StreamProvider.family<Club?, String>((ref, clubId) {
|
||||
final service = ref.watch(clubServiceProvider);
|
||||
return service.clubStream(clubId);
|
||||
});
|
||||
|
||||
/// Realtime stream of the active caller's membership document within a specific Club.
|
||||
final currentMemberProvider = StreamProvider.family<ClubMember?, String>((ref, clubId) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null || user.isAnonymous) {
|
||||
return Stream.value(null);
|
||||
}
|
||||
final service = ref.watch(clubServiceProvider);
|
||||
return service.memberStream(clubId, user.uid);
|
||||
});
|
||||
|
||||
/// Realtime stream of active members for a specific Club (paginated limit 50).
|
||||
final clubMembersProvider = StreamProvider.family<List<ClubMember>, String>((ref, clubId) {
|
||||
final service = ref.watch(clubServiceProvider);
|
||||
return service.activeMembersStream(clubId);
|
||||
});
|
||||
|
||||
/// Realtime stream of pending join requests for a specific Club (leadership only).
|
||||
final pendingClubRequestsProvider = StreamProvider.family<List<ClubMember>, String>((ref, clubId) {
|
||||
final service = ref.watch(clubServiceProvider);
|
||||
return service.pendingRequestsStream(clubId);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,102 @@
|
|||
import '../social/handle_validator.dart';
|
||||
|
||||
/// Result of a Club slug validation check.
|
||||
class ClubSlugValidationResult {
|
||||
final bool isValid;
|
||||
final String? error;
|
||||
final String? normalizedSlug;
|
||||
|
||||
const ClubSlugValidationResult._({
|
||||
required this.isValid,
|
||||
this.error,
|
||||
this.normalizedSlug,
|
||||
});
|
||||
|
||||
factory ClubSlugValidationResult.valid(String normalized) =>
|
||||
ClubSlugValidationResult._(isValid: true, normalizedSlug: normalized);
|
||||
|
||||
factory ClubSlugValidationResult.invalid(String error) =>
|
||||
ClubSlugValidationResult._(isValid: false, error: error);
|
||||
}
|
||||
|
||||
/// Normalization and validation logic for MotorSignal Club slugs.
|
||||
class ClubSlugValidator {
|
||||
static const int minLength = 3;
|
||||
static const int maxLength = 30;
|
||||
|
||||
static final RegExp _formatRegex = RegExp(r'^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$');
|
||||
|
||||
/// Reserved words specific to clubs, chapters, and platform organizations.
|
||||
static const Set<String> clubReservedWords = {
|
||||
'club',
|
||||
'clubs',
|
||||
'motorcycle',
|
||||
'motorcycles',
|
||||
'mc',
|
||||
'chapter',
|
||||
'chapters',
|
||||
'national',
|
||||
'international',
|
||||
'official',
|
||||
'rally',
|
||||
'groupride',
|
||||
'grouprides',
|
||||
'event',
|
||||
'events',
|
||||
'all',
|
||||
'global',
|
||||
'feed',
|
||||
'roster',
|
||||
'members',
|
||||
'settings',
|
||||
'admin',
|
||||
};
|
||||
|
||||
/// Normalizes a raw slug: trims whitespace and lowercases.
|
||||
static String normalize(String rawSlug) => rawSlug.trim().toLowerCase();
|
||||
|
||||
/// Validates a candidate slug format and checks against reserved words.
|
||||
static ClubSlugValidationResult validate(String rawSlug) {
|
||||
final normalized = normalize(rawSlug);
|
||||
|
||||
if (normalized.isEmpty) {
|
||||
return ClubSlugValidationResult.invalid('Club slug cannot be empty');
|
||||
}
|
||||
|
||||
if (normalized.length < minLength) {
|
||||
return ClubSlugValidationResult.invalid(
|
||||
'Club slug must be at least $minLength characters',
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.length > maxLength) {
|
||||
return ClubSlugValidationResult.invalid(
|
||||
'Club slug cannot exceed $maxLength characters',
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.contains('--')) {
|
||||
return ClubSlugValidationResult.invalid(
|
||||
'Club slug cannot contain consecutive hyphens',
|
||||
);
|
||||
}
|
||||
|
||||
if (!_formatRegex.hasMatch(normalized)) {
|
||||
return ClubSlugValidationResult.invalid(
|
||||
'Club slug must start and end with a letter or number, and contain only lowercase letters, numbers, and hyphens',
|
||||
);
|
||||
}
|
||||
|
||||
// Check against standard platform handles (admin, support, motorsignal, etc.)
|
||||
if (HandleValidator.reservedHandles.contains(normalized)) {
|
||||
return ClubSlugValidationResult.invalid('This club slug is reserved');
|
||||
}
|
||||
|
||||
// Check against club-specific reserved words
|
||||
if (clubReservedWords.contains(normalized)) {
|
||||
return ClubSlugValidationResult.invalid('This club slug is reserved');
|
||||
}
|
||||
|
||||
return ClubSlugValidationResult.valid(normalized);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/services/clubs/club_models.dart';
|
||||
|
||||
void main() {
|
||||
group('Club Enums', () {
|
||||
test('ClubVisibility labels and parsing', () {
|
||||
expect(ClubVisibility.public.label, equals('Public'));
|
||||
expect(ClubVisibility.unlisted.label, equals('Unlisted'));
|
||||
expect(ClubVisibility.private.label, equals('Private'));
|
||||
|
||||
expect(ClubVisibility.fromString('public'), equals(ClubVisibility.public));
|
||||
expect(ClubVisibility.fromString('unlisted'), equals(ClubVisibility.unlisted));
|
||||
expect(ClubVisibility.fromString('private'), equals(ClubVisibility.private));
|
||||
expect(ClubVisibility.fromString('unknown'), equals(ClubVisibility.public));
|
||||
expect(ClubVisibility.fromString(null), equals(ClubVisibility.public));
|
||||
});
|
||||
|
||||
test('ClubAdmissionPolicy labels and parsing', () {
|
||||
expect(ClubAdmissionPolicy.open.label, equals('Open to Join'));
|
||||
expect(ClubAdmissionPolicy.request.label, equals('Request to Join'));
|
||||
expect(ClubAdmissionPolicy.inviteOnly.label, equals('Invite Only'));
|
||||
|
||||
expect(ClubAdmissionPolicy.fromString('open'), equals(ClubAdmissionPolicy.open));
|
||||
expect(ClubAdmissionPolicy.fromString('request'), equals(ClubAdmissionPolicy.request));
|
||||
expect(ClubAdmissionPolicy.fromString('inviteOnly'), equals(ClubAdmissionPolicy.inviteOnly));
|
||||
expect(ClubAdmissionPolicy.fromString('other'), equals(ClubAdmissionPolicy.request));
|
||||
expect(ClubAdmissionPolicy.fromString(null), equals(ClubAdmissionPolicy.request));
|
||||
});
|
||||
|
||||
test('ClubRole labels, rank hierarchy, and parsing', () {
|
||||
expect(ClubRole.owner.label, equals('Owner'));
|
||||
expect(ClubRole.admin.label, equals('Admin'));
|
||||
expect(ClubRole.member.label, equals('Member'));
|
||||
|
||||
// Hierarchy
|
||||
expect(ClubRole.owner.isAtLeast(ClubRole.owner), isTrue);
|
||||
expect(ClubRole.owner.isAtLeast(ClubRole.admin), isTrue);
|
||||
expect(ClubRole.owner.isAtLeast(ClubRole.member), isTrue);
|
||||
|
||||
expect(ClubRole.admin.isAtLeast(ClubRole.owner), isFalse);
|
||||
expect(ClubRole.admin.isAtLeast(ClubRole.admin), isTrue);
|
||||
expect(ClubRole.admin.isAtLeast(ClubRole.member), isTrue);
|
||||
|
||||
expect(ClubRole.member.isAtLeast(ClubRole.owner), isFalse);
|
||||
expect(ClubRole.member.isAtLeast(ClubRole.admin), isFalse);
|
||||
expect(ClubRole.member.isAtLeast(ClubRole.member), isTrue);
|
||||
|
||||
expect(ClubRole.fromString('owner'), equals(ClubRole.owner));
|
||||
expect(ClubRole.fromString('admin'), equals(ClubRole.admin));
|
||||
expect(ClubRole.fromString('member'), equals(ClubRole.member));
|
||||
expect(ClubRole.fromString(null), equals(ClubRole.member));
|
||||
});
|
||||
|
||||
test('ClubMemberStatus classification and parsing', () {
|
||||
expect(ClubMemberStatus.active.isActive, isTrue);
|
||||
expect(ClubMemberStatus.invited.isActive, isFalse);
|
||||
expect(ClubMemberStatus.requested.isActive, isFalse);
|
||||
|
||||
expect(ClubMemberStatus.invited.isPending, isTrue);
|
||||
expect(ClubMemberStatus.requested.isPending, isTrue);
|
||||
expect(ClubMemberStatus.active.isPending, isFalse);
|
||||
|
||||
expect(ClubMemberStatus.declined.isHistorical, isTrue);
|
||||
expect(ClubMemberStatus.left.isHistorical, isTrue);
|
||||
expect(ClubMemberStatus.removed.isHistorical, isTrue);
|
||||
expect(ClubMemberStatus.active.isHistorical, isFalse);
|
||||
|
||||
expect(ClubMemberStatus.fromString('active'), equals(ClubMemberStatus.active));
|
||||
expect(ClubMemberStatus.fromString('invited'), equals(ClubMemberStatus.invited));
|
||||
expect(ClubMemberStatus.fromString('requested'), equals(ClubMemberStatus.requested));
|
||||
expect(ClubMemberStatus.fromString('left'), equals(ClubMemberStatus.left));
|
||||
expect(ClubMemberStatus.fromString('removed'), equals(ClubMemberStatus.removed));
|
||||
expect(ClubMemberStatus.fromString('declined'), equals(ClubMemberStatus.declined));
|
||||
expect(ClubMemberStatus.fromString(null), equals(ClubMemberStatus.declined));
|
||||
});
|
||||
});
|
||||
|
||||
group('Club Model', () {
|
||||
final now = DateTime(2026, 9, 6, 12, 0, 0);
|
||||
|
||||
test('full serialization roundtrip (JSON and Firestore)', () {
|
||||
final club = Club(
|
||||
id: 'club_1',
|
||||
name: 'Bay Area Sport Riders',
|
||||
slug: 'bay-area-sport-riders',
|
||||
description: 'Twisties and canyon carving',
|
||||
visibility: ClubVisibility.public,
|
||||
admissionPolicy: ClubAdmissionPolicy.request,
|
||||
ownerUid: 'user_alice',
|
||||
memberCount: 24,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
bannerUrl: 'https://example.com/banner.png',
|
||||
homeRegion: 'San Francisco Bay Area',
|
||||
motorcycleFocus: 'Sport / Canyon',
|
||||
);
|
||||
|
||||
final json = club.toJson();
|
||||
final fromJson = Club.fromJson(json);
|
||||
|
||||
expect(fromJson.id, equals(club.id));
|
||||
expect(fromJson.name, equals(club.name));
|
||||
expect(fromJson.slug, equals(club.slug));
|
||||
expect(fromJson.description, equals(club.description));
|
||||
expect(fromJson.visibility, equals(club.visibility));
|
||||
expect(fromJson.admissionPolicy, equals(club.admissionPolicy));
|
||||
expect(fromJson.ownerUid, equals(club.ownerUid));
|
||||
expect(fromJson.memberCount, equals(club.memberCount));
|
||||
expect(fromJson.createdAt, equals(club.createdAt));
|
||||
expect(fromJson.updatedAt, equals(club.updatedAt));
|
||||
expect(fromJson.archivedAt, isNull);
|
||||
expect(fromJson.isArchived, isFalse);
|
||||
expect(fromJson.logoUrl, equals(club.logoUrl));
|
||||
expect(fromJson.bannerUrl, equals(club.bannerUrl));
|
||||
expect(fromJson.homeRegion, equals(club.homeRegion));
|
||||
expect(fromJson.motorcycleFocus, equals(club.motorcycleFocus));
|
||||
|
||||
// Firestore map serialization
|
||||
final fsMap = club.toFirestore();
|
||||
expect(fsMap['createdAt'], isA<Timestamp>());
|
||||
expect(fsMap['updatedAt'], isA<Timestamp>());
|
||||
|
||||
// Deserializing from map with Timestamps
|
||||
final fromFs = Club.fromJson(fsMap);
|
||||
expect(fromFs.name, equals(club.name));
|
||||
expect(fromFs.createdAt, equals(club.createdAt));
|
||||
});
|
||||
|
||||
test('copyWith handles updates and null clearing', () {
|
||||
final club = Club(
|
||||
id: 'club_1',
|
||||
name: 'Alpha Club',
|
||||
slug: 'alpha-club',
|
||||
ownerUid: 'alice',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: now,
|
||||
);
|
||||
|
||||
expect(club.isArchived, isTrue);
|
||||
|
||||
final unarchived = club.copyWith(clearArchivedAt: true);
|
||||
expect(unarchived.archivedAt, isNull);
|
||||
expect(unarchived.isArchived, isFalse);
|
||||
|
||||
final updatedName = club.copyWith(name: 'Beta Club');
|
||||
expect(updatedName.name, equals('Beta Club'));
|
||||
expect(updatedName.slug, equals('alpha-club'));
|
||||
});
|
||||
});
|
||||
|
||||
group('ClubMember Model', () {
|
||||
final now = DateTime(2026, 9, 6, 12, 0, 0);
|
||||
|
||||
test('lean serialization roundtrip without duplicated rider profile identity', () {
|
||||
final member = ClubMember(
|
||||
uid: 'user_bob',
|
||||
clubId: 'club_1',
|
||||
status: ClubMemberStatus.active,
|
||||
role: ClubRole.admin,
|
||||
displayRank: 'Road Captain',
|
||||
joinedAt: now,
|
||||
requestedAt: now.subtract(const Duration(days: 1)),
|
||||
invitedByUid: 'user_alice',
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
expect(member.isActive, isTrue);
|
||||
expect(member.isAdmin, isTrue);
|
||||
expect(member.isOwner, isFalse);
|
||||
|
||||
final json = member.toJson();
|
||||
// Ensure no identity duplication
|
||||
expect(json.containsKey('riderHandleSnapshot'), isFalse);
|
||||
expect(json.containsKey('riderDisplayNameSnapshot'), isFalse);
|
||||
expect(json.containsKey('riderAvatarPreset'), isFalse);
|
||||
expect(json.containsKey('riderAvatarUrl'), isFalse);
|
||||
|
||||
final fromJson = ClubMember.fromJson(json);
|
||||
expect(fromJson.uid, equals(member.uid));
|
||||
expect(fromJson.clubId, equals(member.clubId));
|
||||
expect(fromJson.status, equals(member.status));
|
||||
expect(fromJson.role, equals(member.role));
|
||||
expect(fromJson.displayRank, equals('Road Captain'));
|
||||
expect(fromJson.invitedByUid, equals('user_alice'));
|
||||
expect(fromJson.joinedAt, equals(member.joinedAt));
|
||||
});
|
||||
|
||||
test('copyWith handles displayRank clearing', () {
|
||||
final member = ClubMember(
|
||||
uid: 'user_bob',
|
||||
clubId: 'club_1',
|
||||
status: ClubMemberStatus.active,
|
||||
role: ClubRole.member,
|
||||
displayRank: 'Tail Gunner',
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
final cleared = member.copyWith(clearDisplayRank: true);
|
||||
expect(cleared.displayRank, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ClubBan Model', () {
|
||||
final now = DateTime(2026, 9, 6, 12, 0, 0);
|
||||
|
||||
test('ban document serialization preserves reason', () {
|
||||
final ban = ClubBan(
|
||||
uid: 'troublemaker',
|
||||
clubId: 'club_1',
|
||||
bannedByUid: 'alice',
|
||||
bannedAt: now,
|
||||
reason: 'Violating group ride safety rules',
|
||||
);
|
||||
|
||||
final json = ban.toJson();
|
||||
final fromJson = ClubBan.fromJson(json);
|
||||
expect(fromJson.uid, equals('troublemaker'));
|
||||
expect(fromJson.bannedByUid, equals('alice'));
|
||||
expect(fromJson.bannedAt, equals(now));
|
||||
expect(fromJson.reason, equals('Violating group ride safety rules'));
|
||||
});
|
||||
});
|
||||
|
||||
group('ClubHandle Model', () {
|
||||
final now = DateTime(2026, 9, 6, 12, 0, 0);
|
||||
|
||||
test('handle reservation document serialization', () {
|
||||
final handle = ClubHandle(
|
||||
slug: 'norcal-adv',
|
||||
clubId: 'club_norcal',
|
||||
ownerUid: 'alice',
|
||||
createdAt: now,
|
||||
);
|
||||
|
||||
final json = handle.toJson();
|
||||
final fromJson = ClubHandle.fromJson(json);
|
||||
expect(fromJson.slug, equals('norcal-adv'));
|
||||
expect(fromJson.clubId, equals('club_norcal'));
|
||||
expect(fromJson.ownerUid, equals('alice'));
|
||||
expect(fromJson.createdAt, equals(now));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/services/clubs/club_service.dart';
|
||||
|
||||
void main() {
|
||||
group('ClubService Validation & Invariant Contracts', () {
|
||||
test('reports availability correctly when offline', () {
|
||||
final service = ClubService();
|
||||
expect(service.isAvailable, isFalse);
|
||||
});
|
||||
|
||||
test('rejects club creation when unauthenticated or caller UID missing', () async {
|
||||
final service = ClubService();
|
||||
|
||||
expect(
|
||||
() => service.createClub(
|
||||
name: 'Bay Area Riders',
|
||||
slug: 'bay-area-riders',
|
||||
),
|
||||
throwsA(isA<ClubOperationException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
equals('unauthenticated'),
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects club creation with invalid name length', () async {
|
||||
final service = ClubService();
|
||||
|
||||
// Too short (< 3)
|
||||
expect(
|
||||
() => service.createClub(
|
||||
name: 'AB',
|
||||
slug: 'valid-slug',
|
||||
callerUid: 'alice',
|
||||
),
|
||||
throwsA(isA<ClubOperationException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
equals('invalid-name'),
|
||||
)),
|
||||
);
|
||||
|
||||
// Too long (> 50)
|
||||
expect(
|
||||
() => service.createClub(
|
||||
name: 'A' * 51,
|
||||
slug: 'valid-slug',
|
||||
callerUid: 'alice',
|
||||
),
|
||||
throwsA(isA<ClubOperationException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
equals('invalid-name'),
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects club creation with invalid slug format or reserved keywords', () async {
|
||||
final service = ClubService();
|
||||
|
||||
// Invalid slug format (underscores)
|
||||
expect(
|
||||
() => service.createClub(
|
||||
name: 'Valid Name',
|
||||
slug: 'invalid_slug_with_underscores',
|
||||
callerUid: 'alice',
|
||||
),
|
||||
throwsA(isA<ClubOperationException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
equals('invalid-slug'),
|
||||
)),
|
||||
);
|
||||
|
||||
// Reserved slug
|
||||
expect(
|
||||
() => service.createClub(
|
||||
name: 'Valid Name',
|
||||
slug: 'admin',
|
||||
callerUid: 'alice',
|
||||
),
|
||||
throwsA(isA<ClubOperationException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
equals('invalid-slug'),
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects ownership transfer to self', () async {
|
||||
final service = ClubService();
|
||||
|
||||
expect(
|
||||
() => service.transferOwnership(
|
||||
'club_1',
|
||||
'alice',
|
||||
callerUid: 'alice',
|
||||
),
|
||||
throwsA(isA<ClubOperationException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
equals('invalid-target'),
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
test('gracefully returns null/empty for streams when offline', () async {
|
||||
final service = ClubService();
|
||||
|
||||
final club = await service.getClub('club_1');
|
||||
expect(club, isNull);
|
||||
|
||||
final member = await service.getMember('club_1', 'alice');
|
||||
expect(member, isNull);
|
||||
|
||||
final clubStream = service.clubStream('club_1');
|
||||
expect(await clubStream.first, isNull);
|
||||
|
||||
final memberStream = service.memberStream('club_1', 'alice');
|
||||
expect(await memberStream.first, isNull);
|
||||
|
||||
final activeStream = service.activeMembersStream('club_1');
|
||||
expect(await activeStream.first, isEmpty);
|
||||
|
||||
final pendingStream = service.pendingRequestsStream('club_1');
|
||||
expect(await pendingStream.first, isEmpty);
|
||||
|
||||
final myClubsStream = service.myActiveClubsStream('alice');
|
||||
expect(await myClubsStream.first, isEmpty);
|
||||
|
||||
final myInvitesStream = service.myInvitationsStream('alice');
|
||||
expect(await myInvitesStream.first, isEmpty);
|
||||
|
||||
final discovery = await service.discoverPublicClubs();
|
||||
expect(discovery, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/services/clubs/club_slug_validator.dart';
|
||||
|
||||
void main() {
|
||||
group('ClubSlugValidator', () {
|
||||
test('accepts valid slugs', () {
|
||||
final validCases = [
|
||||
'bay-area-riders',
|
||||
'sf-mc',
|
||||
'apex-hunters-99',
|
||||
'adv1200',
|
||||
'track-day-paddock',
|
||||
'moto-club-california',
|
||||
'canyon-carvers',
|
||||
];
|
||||
|
||||
for (final slug in validCases) {
|
||||
final result = ClubSlugValidator.validate(slug);
|
||||
expect(result.isValid, isTrue, reason: 'Expected $slug to be valid');
|
||||
expect(result.normalizedSlug, equals(slug));
|
||||
expect(result.error, isNull);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes slugs with uppercase and whitespace', () {
|
||||
final result = ClubSlugValidator.validate(' Bay-Area-Riders ');
|
||||
expect(result.isValid, isTrue);
|
||||
expect(result.normalizedSlug, equals('bay-area-riders'));
|
||||
});
|
||||
|
||||
test('rejects empty or whitespace-only slug', () {
|
||||
expect(ClubSlugValidator.validate('').isValid, isFalse);
|
||||
expect(ClubSlugValidator.validate(' ').isValid, isFalse);
|
||||
});
|
||||
|
||||
test('rejects slugs shorter than 3 characters', () {
|
||||
final result = ClubSlugValidator.validate('ab');
|
||||
expect(result.isValid, isFalse);
|
||||
expect(result.error, contains('at least 3 characters'));
|
||||
});
|
||||
|
||||
test('rejects slugs longer than 30 characters', () {
|
||||
final longSlug = 'a' * 31;
|
||||
final result = ClubSlugValidator.validate(longSlug);
|
||||
expect(result.isValid, isFalse);
|
||||
expect(result.error, contains('cannot exceed 30 characters'));
|
||||
});
|
||||
|
||||
test('rejects consecutive hyphens', () {
|
||||
final result = ClubSlugValidator.validate('bay--area');
|
||||
expect(result.isValid, isFalse);
|
||||
expect(result.error, contains('consecutive hyphens'));
|
||||
});
|
||||
|
||||
test('rejects leading or trailing hyphens', () {
|
||||
expect(ClubSlugValidator.validate('-bay-area').isValid, isFalse);
|
||||
expect(ClubSlugValidator.validate('bay-area-').isValid, isFalse);
|
||||
});
|
||||
|
||||
test('rejects underscores and special characters', () {
|
||||
expect(ClubSlugValidator.validate('bay_area_mc').isValid, isFalse);
|
||||
expect(ClubSlugValidator.validate('bay.area').isValid, isFalse);
|
||||
expect(ClubSlugValidator.validate('bay@area').isValid, isFalse);
|
||||
expect(ClubSlugValidator.validate('bay!area').isValid, isFalse);
|
||||
});
|
||||
|
||||
test('rejects platform reserved keywords', () {
|
||||
final reservedKeywords = [
|
||||
'admin',
|
||||
'administrator',
|
||||
'motorsignal',
|
||||
'support',
|
||||
'system',
|
||||
'root',
|
||||
'api',
|
||||
'mod',
|
||||
];
|
||||
|
||||
for (final kw in reservedKeywords) {
|
||||
final result = ClubSlugValidator.validate(kw);
|
||||
expect(result.isValid, isFalse, reason: 'Expected $kw to be reserved');
|
||||
expect(result.error, contains('reserved'));
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects club-specific reserved words', () {
|
||||
final clubReserved = [
|
||||
'club',
|
||||
'clubs',
|
||||
'motorcycle',
|
||||
'motorcycles',
|
||||
'chapter',
|
||||
'national',
|
||||
'official',
|
||||
'rally',
|
||||
'groupride',
|
||||
'event',
|
||||
];
|
||||
|
||||
for (final kw in clubReserved) {
|
||||
final result = ClubSlugValidator.validate(kw);
|
||||
expect(result.isValid, isFalse, reason: 'Expected $kw to be reserved');
|
||||
expect(result.error, contains('reserved'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue