bottleneckcrm/mobile/lib/data/models/web_form.dart
Ashwin 2be3f51efc Add comprehensive tests for ticket closing, calendar feed, deal board, macros, session caches, and web form models
- Implement tests for closing tickets via menu and status picker, ensuring correct API interactions and error handling.
- Create tests for calendar feed provider, verifying throttling behavior and success responses.
- Add tests for deal board provider, covering reading and moving deals, including edge cases for card movement and filters.
- Enhance macro tests to validate action parsing and application to tickets, including error handling.
- Introduce session cache tests to ensure proper cleanup on sign-out and validate session-scoped providers.
- Extend web form model tests to cover lead rotation functionality, ensuring correct serialization and defaults.
2026-09-27 06:28:09 +05:30

699 lines
26 KiB
Dart

/// Embeddable web forms, from `WebFormDetailSerializer` (issue #634).
///
/// A published web form is an endpoint anyone on the internet can post to, and
/// every accepted post writes a lead or a ticket into the org (the form's
/// `target`, chosen at creation and fixed once it has submissions). That is closer to a
/// credential than to a record, which is why every write here is admin-only
/// server-side and why this file is careful about three things in particular.
///
/// `is_published` NEVER GOES ON THE WIRE
/// It is read-only on the serializer, so sending it is silently ignored rather
/// than rejected, and a client that sent it would appear to publish while
/// publishing nothing. Publishing has its own endpoint, which validates the
/// source state (already-published is a 400, not a no-op) and the form's shape
/// (no email field, no publish).
///
/// `captcha_secret` IS WRITE-ONLY
/// The API never returns it, so it is absent from `fromJson` and omitted from
/// `toJson` unless somebody typed a new one. Emitting an empty string would
/// blank a working Turnstile secret on any unrelated save, and because the
/// value is never read back nothing would show that it had happened until the
/// next visitor was refused. Turnstile fails closed. `hasCaptchaSecret` is the
/// server's boolean for "one is stored", which is how a screen can tell an
/// empty box apart from a hidden value.
///
/// `order` IS THE SERVER'S TO ASSIGN
/// `WebFormDetailSerializer._write_fields` enumerates the rows it is given and
/// stamps `order` from list position, so the client's job is to send the list
/// in the right order rather than to send its own numbers. `toJson` therefore
/// omits `order` entirely.
library;
import 'lookup_models.dart';
/// One built-in field a web form may collect (a Lead column, or a ticket
/// field).
class WebFormFieldChoice {
const WebFormFieldChoice(this.value, this.label);
final String value;
final String label;
}
/// Mirrored 1:1 from `backend/webforms/constants.py::LEAD_FIELD_CHOICES`, and
/// matching `WEBFORM_LEAD_FIELDS` in `frontend/src/lib/v2/webform-fields.js`.
///
/// A whitelist rather than "any field on Lead": the person filling this in is
/// an anonymous stranger, so which columns they can reach is a decision
/// somebody made. Assignment, pipeline stage, probability and deal value are
/// deliberately absent.
///
/// `title` and `salutation` are easy to confuse and mean different things.
/// `Lead.title` is the subject line ("Website enquiry"); `Lead.salutation` is
/// the honorific ("Ms", "Dr"). The labels say so.
///
/// Drift is contained rather than prevented: `WebFormFieldSerializer
/// .validate_lead_field` refuses anything outside the backend's own list, so a
/// stale entry here is a clean 400 rather than a bad write.
const List<WebFormFieldChoice> webFormLeadFields = [
WebFormFieldChoice('salutation', 'Salutation'),
WebFormFieldChoice('first_name', 'First name'),
WebFormFieldChoice('last_name', 'Last name'),
WebFormFieldChoice('email', 'Email'),
WebFormFieldChoice('phone', 'Phone'),
WebFormFieldChoice('company_name', 'Company name'),
WebFormFieldChoice('job_title', 'Job title'),
WebFormFieldChoice('website', 'Website'),
WebFormFieldChoice('title', 'Subject'),
WebFormFieldChoice('description', 'Message'),
WebFormFieldChoice('city', 'City'),
WebFormFieldChoice('state', 'State'),
WebFormFieldChoice('country', 'Country'),
WebFormFieldChoice('postcode', 'Postal code'),
WebFormFieldChoice('industry', 'Industry'),
];
/// What a ticket form may collect, mirrored 1:1 from
/// `backend/webforms/constants.py::TICKET_FIELD_CHOICES` and matching
/// `WEBFORM_TICKET_FIELDS` on the web. `name` and `description` land on the
/// ticket; the rest describe the person and only reach a contact the
/// submission creates. Priority, type and assignment are the form's own
/// settings, never the visitor's.
const List<WebFormFieldChoice> webFormTicketFields = [
WebFormFieldChoice('email', 'Email'),
WebFormFieldChoice('first_name', 'First name'),
WebFormFieldChoice('last_name', 'Last name'),
WebFormFieldChoice('phone', 'Phone'),
WebFormFieldChoice('company_name', 'Company name'),
WebFormFieldChoice('name', 'Subject'),
WebFormFieldChoice('description', 'Message'),
];
/// Case priorities and types, mirroring `PRIORITY_CHOICE` and `CASE_TYPE` in
/// `backend/common/utils.py`. The server validates both.
const List<String> ticketPriorities = ['Low', 'Normal', 'High', 'Urgent'];
const List<String> ticketTypes = ['Question', 'Incident', 'Problem'];
/// The one field a form must collect before it can be published, mirroring
/// `REQUIRED_LEAD_FIELD` and `REQUIRED_TICKET_FIELD` (both "email"). On a
/// lead form it is the key the submission service dedupes on; on a ticket
/// form it is how the ticket finds or creates its contact.
const String requiredLeadField = 'email';
/// The built-in whitelist for a form [target].
List<WebFormFieldChoice> builtinFieldsFor(String target) =>
target == WebForm.targetTicket ? webFormTicketFields : webFormLeadFields;
/// The `CustomFieldDefinition.target_model` a form [target] may collect.
String customFieldModelFor(String target) =>
target == WebForm.targetTicket ? 'Case' : 'Lead';
String builtinFieldLabel(String target, String value) {
for (final field in builtinFieldsFor(target)) {
if (field.value == value) return field.label;
}
return value;
}
/// Whether this list would survive `WebFormPublishView`'s check.
///
/// A display hint, so the screen can explain a disabled Publish button before
/// the round trip. The server runs the same check and is what decides. Only a
/// row of the form's own built-in source counts: the server refuses a lead row
/// on a ticket form, so it must not read as publishable here.
bool hasRequiredField(
List<WebFormField> fields, {
String target = WebForm.targetLead,
}) {
final source = WebFormField.builtinSourceFor(target);
return fields.any(
(f) => f.source == source && f.builtinField == requiredLeadField,
);
}
/// One ordered question on a form.
///
/// The row IS the field mapping: `leadField` names the Lead column a value
/// lands in, `ticketField` a ticket form's field, or `customField` a
/// CustomFieldDefinition whose value lands in the record's `custom_fields`.
/// Exactly one is set, and `source` says which. That is a serializer check and
/// a database `CheckConstraint` as well as [isComplete] here.
class WebFormField {
const WebFormField({
this.id,
required this.source,
this.leadField = '',
this.ticketField = '',
this.customField,
this.label = '',
this.placeholder = '',
this.isRequired = false,
});
static const String sourceLead = 'lead';
static const String sourceTicket = 'ticket';
static const String sourceCustom = 'custom';
/// The row source for a built-in field on a form of [target].
static String builtinSourceFor(String target) =>
target == WebForm.targetTicket ? sourceTicket : sourceLead;
/// Null until the row has been saved. A row added on this screen has no id,
/// and the whole list is replaced on every save, so nothing depends on one.
final String? id;
final String source;
final String leadField;
final String ticketField;
final String? customField;
final String label;
final String placeholder;
final bool isRequired;
bool get isCustom => source == sourceCustom;
/// The whitelist key this row names: its ticket field on a ticket row, its
/// Lead column otherwise, and '' on a custom row.
String get builtinField => switch (source) {
sourceTicket => ticketField,
sourceCustom => '',
_ => leadField,
};
/// Whether the row is complete enough to save. Mirrors `isFieldComplete` in
/// `frontend/src/lib/v2/webform-fields.js`, and behind both of them the
/// `web_form_field_exactly_one_target` constraint.
bool get isComplete {
if (label.trim().isEmpty) return false;
return isCustom
? (customField?.isNotEmpty ?? false)
: builtinField.isNotEmpty;
}
/// The whitelist label of [builtinField], or the key itself when unknown.
String get builtinLabel => builtinFieldLabel(
source == sourceTicket ? WebForm.targetTicket : WebForm.targetLead,
builtinField,
);
/// What the visitor sees, falling back to the target's own name so a row
/// mid-edit still reads as something.
String get displayLabel => label.trim().isNotEmpty ? label : builtinLabel;
factory WebFormField.fromJson(Map<String, dynamic> json) {
return WebFormField(
id: json['id']?.toString(),
source: json['source']?.toString() ?? sourceLead,
leadField: json['lead_field']?.toString() ?? '',
ticketField: json['ticket_field']?.toString() ?? '',
customField: json['custom_field']?.toString(),
label: json['label'] as String? ?? '',
placeholder: json['placeholder'] as String? ?? '',
isRequired: json['is_required'] as bool? ?? false,
);
}
/// Exactly the keys `WebFormFieldSerializer` accepts.
///
/// `order` is absent: the server assigns it from list position (see the
/// library doc-comment), so including it would suggest a client could
/// reorder a form by sending its own numbers. `id` is read-only.
Map<String, dynamic> toJson() {
return {
'source': source,
'lead_field': source == sourceLead ? leadField : '',
'ticket_field': source == sourceTicket ? ticketField : '',
'custom_field': isCustom ? customField : null,
'label': label,
'placeholder': placeholder,
'is_required': isRequired,
};
}
WebFormField copyWith({
String? source,
String? leadField,
String? ticketField,
String? customField,
bool clearCustomField = false,
String? label,
String? placeholder,
bool? isRequired,
}) {
return WebFormField(
id: id,
source: source ?? this.source,
leadField: leadField ?? this.leadField,
ticketField: ticketField ?? this.ticketField,
customField: clearCustomField ? null : (customField ?? this.customField),
label: label ?? this.label,
placeholder: placeholder ?? this.placeholder,
isRequired: isRequired ?? this.isRequired,
);
}
}
/// One embeddable form.
class WebForm {
const WebForm({
required this.id,
this.name = '',
this.target = targetLead,
this.isPublished = false,
this.allowedOrigins = const [],
this.submitButtonLabel = 'Submit',
this.successMode = successMessage,
this.successMessageText = '',
this.redirectUrl = '',
this.assignmentMode = assignPerson,
this.assignTo,
this.storedAssignee,
this.rotationMembers = const [],
this.storedRotationMembers = const [],
this.rotationCap,
this.rotationLastAssigned,
this.notifyProfiles = const [],
this.leadSource = 'other',
this.tags = const [],
this.ticketPriority = 'Normal',
this.ticketType = '',
this.captchaProvider = '',
this.captchaSiteKey = '',
this.hasCaptchaSecret = false,
this.rejectDisposableEmail = true,
this.embedHtml = '',
this.embedJs = '',
this.fields = const [],
this.submissionCount = 0,
this.fieldCount = 0,
this.createdAt,
});
static const String targetLead = 'lead';
static const String targetTicket = 'ticket';
static const String successMessage = 'message';
static const String successRedirect = 'redirect';
static const String captchaNone = '';
static const String captchaTurnstile = 'turnstile';
static const String assignPerson = 'person';
static const String assignRotation = 'rotation';
final String id;
final String name;
/// What an accepted submission creates: [targetLead] or [targetTicket].
/// Chosen when the form is created and never sent on an update, because the
/// server refuses to change it once the form has submissions.
final String target;
final bool isPublished;
final List<String> allowedOrigins;
final String submitButtonLabel;
final String successMode;
/// Named `successMessageText` because `successMessage` is the mode constant.
/// Two things called `successMessage` on one class is how the wrong one gets
/// rendered into a form a customer sees.
final String successMessageText;
final String redirectUrl;
/// How a new lead is assigned: [assignPerson] (to [assignTo]) or
/// [assignRotation] (to the next eligible [rotationMembers] entry). Lead
/// forms only; the server refuses rotation on a ticket form.
final String assignmentMode;
final String? assignTo;
/// Who the stored `assign_to` is, from `assign_to_details`, and whether they
/// are still active. The people picker lists active members only, so this
/// is how the editor can still offer a deactivated assignee rather than
/// dropping them on the next save. Describes the stored value, not the
/// draft, so `copyWith` carries it through unchanged.
final UserLookup? storedAssignee;
/// Who a rotation form hands new leads to, in turn by id. The server skips
/// a deactivated member, one outside the org, and one holding [rotationCap]
/// open leads.
final List<String> rotationMembers;
/// Who the stored [rotationMembers] are, from `rotation_members_details`,
/// active or not, for the same reason as [storedAssignee]: the picker lists
/// active members only. Describes the stored value, not the draft.
final List<UserLookup> storedRotationMembers;
/// Most open leads a member may hold before the rotation passes them over.
/// Null is no cap.
final int? rotationCap;
/// Whoever received the last rotated lead. Read-only: only the server's
/// submission path moves it, so it is never sent.
final UserLookup? rotationLastAssigned;
final List<String> notifyProfiles;
final String leadSource;
final List<String> tags;
/// Ticket forms only: the priority and type every ticket is opened with.
/// Set by the form, never by the visitor. An empty [ticketType] is "no
/// type".
final String ticketPriority;
final String ticketType;
final String captchaProvider;
final String captchaSiteKey;
/// Whether a secret is stored, never which one. See the library comment.
final bool hasCaptchaSecret;
final bool rejectDisposableEmail;
/// Built server-side, because they need the API's own base URL and this app
/// only knows the one it was configured with. Never assembled here.
final String embedHtml;
final String embedJs;
final List<WebFormField> fields;
/// Present on the list payload, absent on the detail one.
final int submissionCount;
final int fieldCount;
final DateTime? createdAt;
bool get isTicket => target == targetTicket;
bool get isRotation => assignmentMode == assignRotation;
bool get usesTurnstile => captchaProvider == captchaTurnstile;
bool get redirectsOnSuccess => successMode == successRedirect;
/// What stops this form being published, or null when nothing does.
///
/// Kept close to `WebFormPublishView`'s own wording so the screen and the
/// server never read like different rules. The server is what decides.
String? get publishBlocker {
if (fields.isEmpty) return 'Add at least one field first.';
if (!hasRequiredField(fields, target: target)) {
return isTicket
? 'Add an email field before publishing. It is how each ticket '
'finds its contact, or creates one.'
: 'Add an email field before publishing. It is what lets a repeat '
'submission update the existing lead instead of failing.';
}
if (!fields.every((f) => f.isComplete)) {
return 'Every field needs a label and something to write into.';
}
if (redirectsOnSuccess && redirectUrl.isEmpty) {
return 'This form redirects on success but has no redirect URL set.';
}
return null;
}
static UserLookup? _profile(dynamic value) =>
value is Map ? UserLookup.fromJson(value.cast<String, dynamic>()) : null;
static List<String> _stringList(dynamic value) {
if (value is! List) return const [];
return value.map((v) => v.toString()).toList(growable: false);
}
factory WebForm.fromJson(Map<String, dynamic> json) {
final rawFields = json['fields'];
final fields = <WebFormField>[];
if (rawFields is List) {
final rows = rawFields.whereType<Map<String, dynamic>>().toList();
// Sorted rather than trusted. The API orders by `order` already, so this
// is a no-op on every response it actually sends; it is here because the
// order IS the form, and a list that silently arrived shuffled would ask
// a visitor for their message before their email.
rows.sort((a, b) {
final left = a['order'];
final right = b['order'];
return (left is int ? left : 0).compareTo(right is int ? right : 0);
});
fields.addAll(rows.map(WebFormField.fromJson));
}
return WebForm(
id: json['id']?.toString() ?? '',
name: json['name'] as String? ?? '',
target: json['target']?.toString() ?? targetLead,
isPublished: json['is_published'] as bool? ?? false,
allowedOrigins: _stringList(json['allowed_origins']),
submitButtonLabel: json['submit_button_label'] as String? ?? 'Submit',
successMode: json['success_mode']?.toString() ?? successMessage,
successMessageText: json['success_message'] as String? ?? '',
redirectUrl: json['redirect_url'] as String? ?? '',
assignmentMode: json['assignment_mode']?.toString() ?? assignPerson,
assignTo: json['assign_to']?.toString(),
storedAssignee: _profile(json['assign_to_details']),
rotationMembers: _stringList(json['rotation_members']),
storedRotationMembers: json['rotation_members_details'] is List
? (json['rotation_members_details'] as List)
.map(_profile)
.whereType<UserLookup>()
.toList(growable: false)
: const [],
rotationCap: json['rotation_cap'] as int?,
rotationLastAssigned: _profile(json['rotation_last_assigned_details']),
notifyProfiles: _stringList(json['notify_profiles']),
leadSource: json['lead_source']?.toString() ?? 'other',
tags: _stringList(json['tags']),
ticketPriority: json['ticket_priority']?.toString() ?? 'Normal',
ticketType: json['ticket_type']?.toString() ?? '',
captchaProvider: json['captcha_provider']?.toString() ?? '',
captchaSiteKey: json['captcha_site_key'] as String? ?? '',
hasCaptchaSecret: json['has_captcha_secret'] as bool? ?? false,
rejectDisposableEmail: json['reject_disposable_email'] as bool? ?? true,
embedHtml: json['embed_html'] as String? ?? '',
embedJs: json['embed_js'] as String? ?? '',
fields: List.unmodifiable(fields),
submissionCount: json['submission_count'] as int? ?? 0,
fieldCount: json['field_count'] as int? ?? fields.length,
createdAt: DateTime.tryParse(json['created_at']?.toString() ?? ''),
);
}
/// The update payload.
///
/// `is_published` and `has_captcha_secret` are absent because both are
/// read-only server-side; `captcha_secret` appears only when [captchaSecret]
/// carries something somebody typed. See the library doc-comment for why
/// each of those matters.
Map<String, dynamic> toJson({String? captchaSecret}) {
final payload = <String, dynamic>{
'name': name,
'allowed_origins': allowedOrigins,
'submit_button_label': submitButtonLabel,
'success_mode': successMode,
'success_message': successMessageText,
'redirect_url': redirectUrl,
'assign_to': assignTo,
'notify_profiles': notifyProfiles,
'lead_source': leadSource,
'tags': tags,
'ticket_priority': ticketPriority,
'ticket_type': ticketType,
'captcha_provider': captchaProvider,
'captcha_site_key': captchaSiteKey,
'reject_disposable_email': rejectDisposableEmail,
'fields': fields.map((f) => f.toJson()).toList(growable: false),
};
// Lead forms only. A ticket form never rotates, and the server refuses
// rotation on one, so it sends none of the three.
if (!isTicket) {
payload['assignment_mode'] = assignmentMode;
payload['rotation_members'] = rotationMembers;
payload['rotation_cap'] = rotationCap;
}
if (captchaSecret != null && captchaSecret.trim().isNotEmpty) {
payload['captcha_secret'] = captchaSecret.trim();
}
return payload;
}
WebForm copyWith({
String? name,
List<String>? allowedOrigins,
String? submitButtonLabel,
String? successMode,
String? successMessageText,
String? redirectUrl,
String? assignmentMode,
String? assignTo,
bool clearAssignTo = false,
List<String>? rotationMembers,
int? rotationCap,
bool clearRotationCap = false,
List<String>? notifyProfiles,
String? leadSource,
List<String>? tags,
String? ticketPriority,
String? ticketType,
String? captchaProvider,
String? captchaSiteKey,
bool? rejectDisposableEmail,
List<WebFormField>? fields,
}) {
return WebForm(
id: id,
name: name ?? this.name,
target: target,
isPublished: isPublished,
allowedOrigins: allowedOrigins ?? this.allowedOrigins,
submitButtonLabel: submitButtonLabel ?? this.submitButtonLabel,
successMode: successMode ?? this.successMode,
successMessageText: successMessageText ?? this.successMessageText,
redirectUrl: redirectUrl ?? this.redirectUrl,
assignmentMode: assignmentMode ?? this.assignmentMode,
assignTo: clearAssignTo ? null : (assignTo ?? this.assignTo),
storedAssignee: storedAssignee,
rotationMembers: rotationMembers ?? this.rotationMembers,
storedRotationMembers: storedRotationMembers,
rotationCap: clearRotationCap ? null : (rotationCap ?? this.rotationCap),
rotationLastAssigned: rotationLastAssigned,
notifyProfiles: notifyProfiles ?? this.notifyProfiles,
leadSource: leadSource ?? this.leadSource,
tags: tags ?? this.tags,
ticketPriority: ticketPriority ?? this.ticketPriority,
ticketType: ticketType ?? this.ticketType,
captchaProvider: captchaProvider ?? this.captchaProvider,
captchaSiteKey: captchaSiteKey ?? this.captchaSiteKey,
hasCaptchaSecret: hasCaptchaSecret,
rejectDisposableEmail:
rejectDisposableEmail ?? this.rejectDisposableEmail,
embedHtml: embedHtml,
embedJs: embedJs,
fields: fields ?? this.fields,
submissionCount: submissionCount,
fieldCount: fieldCount,
createdAt: createdAt,
);
}
}
/// One attempt at a form, accepted or refused.
///
/// `reject_reason` is deliberately absent from the API's response and so has
/// no field here: naming the control that caught a bot is how the next bot
/// gets past it. The status is what a person acts on.
class WebFormSubmission {
const WebFormSubmission({
required this.id,
this.status = '',
this.leadId,
this.leadName,
this.caseId,
this.caseName,
this.submittedIp,
this.referer = '',
this.createdAt,
});
static const String accepted = 'accepted';
static const String acceptedDuplicate = 'accepted_duplicate';
final String id;
final String status;
final String? leadId;
final String? leadName;
/// The ticket a ticket form's accepted submission opened. Never set beside
/// [leadId]; the server enforces that with a check constraint.
final String? caseId;
final String? caseName;
final String? submittedIp;
final String referer;
final DateTime? createdAt;
/// A merged duplicate counts: a returning visitor whose details filled in
/// blanks on an existing lead reached the org just as surely as a new one.
bool get isAccepted => status == accepted || status == acceptedDuplicate;
String get statusLabel => switch (status) {
accepted => caseId != null ? 'Ticket opened' : 'Lead created',
acceptedDuplicate => 'Merged into an existing lead',
'rejected_spam' => 'Rejected as spam',
'rejected_invalid' => 'Rejected, invalid',
'rejected_captcha' => 'Rejected, captcha',
_ => status,
};
factory WebFormSubmission.fromJson(Map<String, dynamic> json) {
final lead = json['lead']?.toString();
final ticket = json['case']?.toString();
return WebFormSubmission(
id: json['id']?.toString() ?? '',
status: json['status']?.toString() ?? '',
leadId: (lead == null || lead.isEmpty) ? null : lead,
leadName: json['lead_name'] as String?,
caseId: (ticket == null || ticket.isEmpty) ? null : ticket,
caseName: json['case_name'] as String?,
submittedIp: json['submitted_ip'] as String?,
referer: json['referer'] as String? ?? '',
createdAt: DateTime.tryParse(json['created_at']?.toString() ?? ''),
);
}
}
/// Views, submissions and conversion over the API's fixed trailing 30 days.
///
/// The window is not a parameter here because it is not one on the API either.
class WebFormAnalytics {
const WebFormAnalytics({
this.views = 0,
this.submissions = 0,
this.spam = 0,
this.conversionRate = 0,
this.windowDays = 30,
});
final int views;
final int submissions;
final int spam;
final double conversionRate;
final int windowDays;
factory WebFormAnalytics.fromJson(Map<String, dynamic> json) {
final totals = json['totals'];
final t = totals is Map<String, dynamic> ? totals : const {};
final rate = t['conversion_rate'];
return WebFormAnalytics(
views: t['views'] as int? ?? 0,
submissions: t['submissions'] as int? ?? 0,
spam: t['spam'] as int? ?? 0,
conversionRate: rate is num ? rate.toDouble() : 0,
windowDays: json['window_days'] as int? ?? 30,
);
}
}
/// The org-wide counts the list endpoint returns.
///
/// Read from the server rather than counted off the page: the list is
/// paginated, so counting rows would be right until the org's eleventh form
/// and quietly wrong afterwards.
class WebFormTotals {
const WebFormTotals({
this.count = 0,
this.published = 0,
this.submissions30d = 0,
this.spam30d = 0,
});
final int count;
final int published;
final int submissions30d;
final int spam30d;
int get drafts => count - published < 0 ? 0 : count - published;
factory WebFormTotals.fromJson(Map<String, dynamic>? json) {
if (json == null) return const WebFormTotals();
return WebFormTotals(
count: json['count'] as int? ?? 0,
published: json['published'] as int? ?? 0,
submissions30d: json['submissions_30d'] as int? ?? 0,
spam30d: json['spam_30d'] as int? ?? 0,
);
}
}