Firestore query patterns, security rules, and offline sync strategies for AC Techs. Activate when working with Firestore collections, queries, security rules, or offline behavior.
After any change to firestore.rules or firestore.indexes.json:
firebase deploy --only firestore:rules --project actechs-d415e # rules changed
firebase deploy --only firestore:indexes --project actechs-d415e # indexes changed
firebase deploy --only firestore --project actechs-d415e # both changed
npm run lint:firestore-rules in scripts/ before deployment — zero [W] warnings requirednpm test in scripts/tests/ — no PERMISSION_DENIED regressionsPERMISSION_DENIEDAPK install sequence after a rules change:
firebase deploy --only firestore --project actechs-d415e ← FIRST
flutter build apk --release ← SECOND
adb -s <deviceId> uninstall com.actechs.pk ← THIRD (uninstall, not -r)
adb -s <deviceId> install build/.../app-release.apk ← FOURTH
users/{userId} — User profiles (uid, name, role, isActive, createdAt, language)jobs/{jobId} — Job records (auto-id, all work units, expenses, status, timestamps)expenses/{expenseId} — Tech personal expenses (food, petrol, tools etc.) — separate from jobsearnings/{earningId} — Tech additional earnings (bracket sold, scrap, old AC) — separate from jobsac_installs/{installId} — AC unit install logs — separate from jobsshared_install_aggregates/{groupKey} — Shared team install counter docsFirebaseFirestore.instance
.collection('jobs')
.where('techId', isEqualTo: userId)
.orderBy('submittedAt', descending: true)
.snapshots()
final startOfDay = DateTime(now.year, now.month, now.day);
FirebaseFirestore.instance
.collection('jobs')
.where('techId', isEqualTo: userId)
.where('date', isGreaterThanOrEqualTo: Timestamp.fromDate(startOfDay))
.orderBy('date', descending: true)
.snapshots()
FirebaseFirestore.instance
.collection('jobs')
.where('status', isEqualTo: 'pending')
.orderBy('submittedAt', descending: false) // oldest first
.snapshots()
FirebaseFirestore.instance
.collection('jobs')
.where('status', isEqualTo: 'approved')
.where('date', isGreaterThanOrEqualTo: monthStart)
.where('date', isLessThan: monthEnd)
.get()
jobs: techId ASC, submittedAt DESCjobs: techId ASC, date DESCjobs: status ASC, submittedAt ASCjobs: status ASC, date ASCexpenses: techId ASC, date DESCearnings: techId ASC, date DESCshared_install_aggregates: teamMemberIds ASC, createdAt DESC/expenses and /earnings collections are completely separate from /jobs.
Never query expenses from a job provider, and never add expense fields to a job document.
// In ExpenseRepository / EarningRepository
final startOfMonth = DateTime(month.year, month.month);
final endOfMonth = DateTime(month.year, month.month + 1);
_ref
.where('techId', isEqualTo: techId)
.where('date', isGreaterThanOrEqualTo: Timestamp.fromDate(startOfMonth))
.where('date', isLessThan: Timestamp.fromDate(endOfMonth))
.orderBy('date', descending: true)
.snapshots()
.map((snap) => snap.docs
.where((d) => d.data()['isDeleted'] != true) // Dart-layer soft-delete filter
.map((d) => ExpenseModel.fromFirestore(d))
.toList())
Required composite index: techId ASC, date DESC.
final startOfDay = DateTime(now.year, now.month, now.day);
final endOfDay = startOfDay.add(const Duration(days: 1));
_ref
.where('techId', isEqualTo: techId)
.where('date', isGreaterThanOrEqualTo: Timestamp.fromDate(startOfDay))
.where('date', isLessThan: Timestamp.fromDate(endOfDay))
.orderBy('date', descending: true)
.snapshots()
Settlement history is admin-only and infrequently accessed. Use a one-time get() to avoid a
persistent stream listener consuming free-tier reads. Wrap in FutureProvider.autoDispose.
Future<List<JobModel>> fetchSettlementHistory() async {
final snap = await _jobsRef
.where('status', isEqualTo: 'approved')
.where('settlementStatus', whereIn: ['confirmed', 'disputed_final'])
.orderBy('date', descending: true)
.limit(200)
.get();
return snap.docs.map(JobModel.fromFirestore).toList();
}
Required composite index: status ASC, settlementStatus ASC, date DESC.
For viewing entries on a historical date (e.g., from history screen):
// WRONG — creates an extra Firestore listener
Stream<List<EarningModel>> earningsForDay(String techId, DateTime day); // do NOT do this
// CORRECT — derive from existing monthly listener in Riverpod
final dailyEarningsProvider = Provider.autoDispose
.family<AsyncValue<List<EarningModel>>, DateTime>((ref, date) {
final month = DateTime(date.year, date.month);
return ref.watch(monthlyEarningsProvider(month)).whenData(
(list) => list.where((e) =>
e.date?.year == date.year &&
e.date?.month == date.month &&
e.date?.day == date.day).toList(),
);
});
This reuses the already-open monthly listener — zero extra Firestore reads.
Firestore local persistence is enabled by default in Flutter. When offline:
metadata.isFromCachenpm run lint:firestore-rules and npm test from scripts/ before deploying Firestore rules.maximum of 1000 expressions messages as hard failures even when tests pass.// Tech watches all shared groups they belong to — zero client-side filtering
FirebaseFirestore.instance
.collection('shared_install_aggregates')
.where('teamMemberIds', arrayContains: uid)
.orderBy('createdAt', descending: true)
.snapshots()
Required composite index: teamMemberIds ASC, createdAt DESC.
teamMemberIds[0] is ALWAYS the createdBy uid (first submitter)teamMemberNames is a parallel array (same index order as teamMemberIds)request.resource.data.teamMemberIds.size() <= 10teamMemberIds must contain request.auth.uidauthUidOrEmpty() in resource.data.teamMemberIds (any team member, not just creator)Docs created before teamMemberIds was added may lack the field. In rules, use:
resource.data.get('teamMemberIds', []).hasAll([authUidOrEmpty()])
Never dereference resource.data.teamMemberIds directly on docs that may predate the field.
If a tech is deactivated/archived while in a teamMemberIds array:
teamMemberIds is needed — the slot simply has no future submissions// WRONG — permanent, unrecoverable
await _ref.doc(id).delete();
// CORRECT — soft archive
// NOTE: archiving a shared install job does NOT roll back aggregate consumed* counters.
// Admin flush + rebuild is the reconciliation path if discrepancy detected.
await _ref.doc(id).update({
'isDeleted': true,
'deletedAt': FieldValue.serverTimestamp(),
});
// In repository stream mapper — filter before mapping to model
.where((snap) => snap.data()?['isDeleted'] != true)
.map((snap) => Model.fromFirestore(snap))
Prefer Dart-layer filtering when the dataset per user is small (< 1000 docs). Only add a Firestore where('isDeleted', isEqualTo: false) query filter when scaling requires it — that would need a new composite index per query.
await _ref.doc(id).update({
'isDeleted': false,
'deletedAt': FieldValue.delete(),
});
Archiving a shared install job does NOT decrement aggregate consumed* counters on shared_install_aggregates. This is intentional:
Aggregates with no new contributions in >30 days are considered stale. This is a FutureProvider pattern (not a stream) to avoid persistent listeners.
Future<List<SharedInstallAggregate>> fetchStaleSharedAggregates({
Duration threshold = const Duration(days: 30),
}) async {
final cutoff = DateTime.now().subtract(threshold);
final snap = await _firestore
.collection(AppConstants.sharedInstallAggregatesCollection)
.where('isDeleted', isEqualTo: false) // only active aggregates
.get();
return snap.docs
.map(SharedInstallAggregate.fromFirestore)
.where((agg) => !agg.isFullyConsumed && agg.createdAt.isBefore(cutoff))
.toList();
}
Future<void> archiveStaleSharedInstall(String groupKey) async {
final batch = _firestore.batch();
// Soft-delete the aggregate doc
batch.update(
_firestore.collection(AppConstants.sharedInstallAggregatesCollection).doc(groupKey),
{'isDeleted': true, 'deletedAt': FieldValue.serverTimestamp()},
);
// Soft-delete associated job docs referencing this groupKey
final jobSnap = await _firestore.collection(AppConstants.jobsCollection)
.where('sharedInstallGroupKey', isEqualTo: groupKey).get();
for (final doc in jobSnap.docs) {
batch.update(doc.reference, {'isDeleted': true, 'deletedAt': FieldValue.serverTimestamp()});
}
await batch.commit();
}
consumed* counters