bottleneckcrm/mobile/lib/data/models/crm_document.dart
Ashwin f132d17d0c feat: enhance profile screen with additional information and links
- Added teams and last signed in information to the profile screen.
- Introduced new link rows for API tokens, goals, timesheet, and tasks.
- Implemented a new _LinkRow widget for better navigation and UI consistency.

fix: update attachment handling in task and ticket detail screens

- Changed attachment opening logic to use API for downloads instead of direct file paths.
- Ensured that attachments without files are handled gracefully.

feat: add contact management to ticket form and detail screens

- Included contact selection in the ticket form, allowing users to link contacts to tickets.
- Updated ticket detail screen to display associated contacts.

refactor: improve API service for file uploads

- Modified the API service to support both POST and PUT methods for file uploads.

test: add unit tests for ticket contacts and document download URLs

- Created tests to ensure correct handling of ticket contacts and document download logic.
- Verified that the absence of contacts does not cause crashes and that payloads are correctly formed.
2026-08-08 14:18:17 +05:30

256 lines
9 KiB
Dart

/// Shared files, from `/api/documents/`.
///
/// Three access rules, and they are deliberately different widths. Reading is
/// broad (`_visible_to`: the uploader, anyone the document is shared with, any
/// member of a shared team, or an admin) and the server has already applied it,
/// so every row this app receives is one the viewer may open. Editing and
/// deleting are narrow and identical (`_may_write` == `_may_delete`: the
/// uploader or an admin), because a share hands someone a copy to work with,
/// not the original to rewrite. Uploading is open to any member.
///
/// [canWriteDocument] mirrors the narrow rule so a row never offers an action
/// the server would refuse. It is not what keeps anyone out.
library;
/// The two states the backend accepts (`Document.DOCUMENT_STATUS_CHOICE`).
///
/// Nothing here calls `inactive` "deleted". It is an archive: the row stays,
/// the file stays, and the only thing that changes is that the list stops
/// showing it by default.
const List<String> documentStatuses = ['active', 'inactive'];
String documentStatusLabel(String status) =>
status == 'inactive' ? 'Archived' : 'Active';
/// The icon buckets a filename falls into. Anything outside the three named
/// groups collapses to a generic file, which is what the web does too.
enum DocumentKind { pdf, sheet, text, file }
DocumentKind documentKind(String path) {
final dot = path.lastIndexOf('.');
final ext = dot == -1 ? '' : path.substring(dot + 1).toLowerCase();
if (ext == 'pdf') return DocumentKind.pdf;
if (const ['xls', 'xlsx', 'csv', 'ods', 'numbers'].contains(ext)) {
return DocumentKind.sheet;
}
if (const [
'txt',
'md',
'markdown',
'doc',
'docx',
'rtf',
'odt',
].contains(ext)) {
return DocumentKind.text;
}
return DocumentKind.file;
}
/// The stored filename, without the upload directory the API prefixes.
String documentFileName(String path) {
final slash = path.lastIndexOf('/');
return slash == -1 ? path : path.substring(slash + 1);
}
/// A size for a person. Null means the server could not stat the file (a stale
/// path, an unsynced media directory), which is not an error worth alarming
/// anyone about, so it reads as unknown rather than as zero.
String documentSizeLabel(int? bytes) {
if (bytes == null) return 'Size unknown';
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(0)} kB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
/// Who can reach a document besides its uploader and the org's admins.
///
/// A team is named rather than flattened to its members, with the count beside
/// it, because that is what the share actually means: people joining and
/// leaving the team change who it reaches, and a list of today's members would
/// go stale the moment somebody joined.
String documentShareSummary(CrmDocument doc) {
if (doc.sharedTo.isEmpty && doc.teams.isEmpty) {
return 'Shared with nobody. Only you and an admin can open it.';
}
final parts = <String>[];
if (doc.sharedTo.isNotEmpty) {
parts.add(doc.sharedTo.map((p) => p.name).join(', '));
}
for (final team in doc.teams) {
parts.add('${team.name} (${team.memberCount})');
}
return parts.join(' · ');
}
/// Whether this viewer may edit or delete [doc].
///
/// Mirrors `DocumentDetailView._may_write`, which is `_may_delete`: an admin,
/// or the person who uploaded it. Compared on email, the one identifier this
/// app and the API agree on for a person, because `created_by` is serialized by
/// `UserSerializer` and the app knows its own user by email.
///
/// False when the uploader is unknown, so an unparsed row hides the action
/// rather than offering one that would 403.
bool canWriteDocument({
required bool isAdmin,
required String? myEmail,
required String? uploaderEmail,
}) {
if (isAdmin) return true;
final me = myEmail?.trim().toLowerCase();
final owner = uploaderEmail?.trim().toLowerCase();
if (me == null || me.isEmpty || owner == null || owner.isEmpty) return false;
return me == owner;
}
/// A person a document is shared with.
class DocumentShare {
const DocumentShare({required this.id, required this.name});
/// A PROFILE id, which is what `shared_to` takes. Not the user id.
final String id;
final String name;
factory DocumentShare.fromJson(Map<String, dynamic> json) {
final details = json['user_details'] as Map<String, dynamic>?;
final name = details?['name']?.toString() ?? '';
final email = details?['email']?.toString() ?? '';
return DocumentShare(
id: json['id']?.toString() ?? '',
name: name.trim().isNotEmpty
? name.trim()
: (email.isNotEmpty ? email : 'Unnamed'),
);
}
}
/// A team a document is shared with.
class DocumentTeamShare {
const DocumentTeamShare({
required this.id,
required this.name,
this.memberCount = 0,
});
final String id;
final String name;
/// How many people the share actually reaches. A bare team name does not say.
final int memberCount;
factory DocumentTeamShare.fromJson(Map<String, dynamic> json) =>
DocumentTeamShare(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
memberCount: (json['member_count'] as num?)?.toInt() ?? 0,
);
}
/// One document, from a `DocumentSerializer` row.
///
/// Named `CrmDocument` rather than `Document` so it cannot be confused with
/// the several `Document` types Flutter and its packages already carry.
class CrmDocument {
const CrmDocument({
required this.id,
required this.title,
required this.documentFile,
required this.status,
this.sharedTo = const [],
this.teams = const [],
this.sizeBytes,
this.createdAt,
this.uploaderName = 'Unknown',
this.uploaderEmail,
});
final String id;
final String title;
/// A stored path, displayed and never turned into a URL here. Building a
/// media URL client-side is how a private file becomes a public one; the
/// server decides who gets the bytes. Download through
/// `ApiConfig.documentDownload(id)`, which is gated by the same read
/// predicate as the record.
final String documentFile;
final String status;
final List<DocumentShare> sharedTo;
final List<DocumentTeamShare> teams;
final int? sizeBytes;
final DateTime? createdAt;
final String uploaderName;
/// From `created_by`, a `UserSerializer` row, so this is the USER's email.
final String? uploaderEmail;
bool get isArchived => status == 'inactive';
/// Whether there is anything to download. A row with no file still lists,
/// so the action has to be offered conditionally.
bool get hasFile => documentFile.trim().isNotEmpty;
DocumentKind get kind => documentKind(documentFile);
String get fileName => documentFileName(documentFile);
factory CrmDocument.fromJson(Map<String, dynamic> json) {
final createdBy = json['created_by'] as Map<String, dynamic>?;
final name = createdBy?['name']?.toString() ?? '';
final email = createdBy?['email']?.toString();
return CrmDocument(
id: json['id']?.toString() ?? '',
title: json['title']?.toString() ?? '',
documentFile: json['document_file']?.toString() ?? '',
status: json['status']?.toString() ?? 'active',
sharedTo: (json['shared_to'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(DocumentShare.fromJson)
.toList(),
teams: (json['teams'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(DocumentTeamShare.fromJson)
.toList(),
sizeBytes: (json['size_bytes'] as num?)?.toInt(),
createdAt: DateTime.tryParse(json['created_at']?.toString() ?? ''),
uploaderName: name.trim().isNotEmpty
? name.trim()
: (email != null && email.isNotEmpty ? email : 'Unknown'),
uploaderEmail: email,
);
}
}
/// The header numbers, computed over every row rather than one page. There is
/// no summary endpoint.
class DocumentTotals {
const DocumentTotals({
this.count = 0,
this.active = 0,
this.archived = 0,
this.unshared = 0,
});
final int count;
final int active;
final int archived;
/// Given to nobody and no team, so reachable only by whoever uploaded it and
/// by an admin. Not an error, so the screen states it without alarm.
final int unshared;
}
DocumentTotals documentTotals(List<CrmDocument> docs) => DocumentTotals(
count: docs.length,
active: docs.where((d) => d.status == 'active').length,
archived: docs.where((d) => d.status == 'inactive').length,
unshared: docs.where((d) => d.sharedTo.isEmpty && d.teams.isEmpty).length,
);
/// The one rule the write serializer enforces that a person can hit by hand.
///
/// `DocumentCreateSerializer` makes `title` required and refuses a title
/// already used in the org, case-insensitively. The duplicate check needs the
/// server, so only the required part is mirrored: an empty title otherwise
/// comes back as a field error rather than something readable.
String? validateDocumentTitle(String title) =>
title.trim().isEmpty ? 'Give the document a title.' : null;