Initial commit

This commit is contained in:
2026-02-09 20:10:42 +08:00
parent 1a1cbb1bb3
commit 1f16da8f88
172 changed files with 12461 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'supabase_provider.dart';
final adminUserControllerProvider = Provider<AdminUserController>((ref) {
final client = ref.watch(supabaseClientProvider);
return AdminUserController(client);
});
class AdminUserStatus {
AdminUserStatus({required this.email, required this.bannedUntil});
final String? email;
final DateTime? bannedUntil;
bool get isLocked {
if (bannedUntil == null) return false;
return bannedUntil!.isAfter(DateTime.now().toUtc());
}
}
class AdminUserController {
AdminUserController(this._client);
final SupabaseClient _client;
Future<void> updateProfile({
required String userId,
required String fullName,
required String role,
}) async {
await _client
.from('profiles')
.update({'full_name': fullName, 'role': role})
.eq('id', userId);
}
Future<void> updateRole({
required String userId,
required String role,
}) async {
await _client.from('profiles').update({'role': role}).eq('id', userId);
}
Future<void> setPassword({
required String userId,
required String password,
}) async {
await _invokeAdminFunction(
action: 'set_password',
payload: {'userId': userId, 'password': password},
);
}
Future<void> setLock({required String userId, required bool locked}) async {
await _invokeAdminFunction(
action: 'set_lock',
payload: {'userId': userId, 'locked': locked},
);
}
Future<AdminUserStatus> fetchStatus(String userId) async {
final data = await _invokeAdminFunction(
action: 'get_user',
payload: {'userId': userId},
);
final user = (data as Map<String, dynamic>)['user'] as Map<String, dynamic>;
final bannedUntilRaw = user['banned_until'] as String?;
return AdminUserStatus(
email: user['email'] as String?,
bannedUntil: bannedUntilRaw == null
? null
: DateTime.tryParse(bannedUntilRaw),
);
}
Future<dynamic> _invokeAdminFunction({
required String action,
required Map<String, dynamic> payload,
}) async {
final response = await _client.functions.invoke(
'admin_user_management',
body: {'action': action, ...payload},
);
if (response.status != 200) {
throw Exception(_extractErrorMessage(response.data));
}
return response.data;
}
String _extractErrorMessage(dynamic data) {
if (data is Map<String, dynamic>) {
final error = data['error'];
if (error is String && error.trim().isNotEmpty) {
return error;
}
final message = data['message'];
if (message is String && message.trim().isNotEmpty) {
return message;
}
}
return 'Admin request failed.';
}
}
+69
View File
@@ -0,0 +1,69 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'supabase_provider.dart';
final authStateChangesProvider = StreamProvider<AuthState>((ref) {
final client = ref.watch(supabaseClientProvider);
return client.auth.onAuthStateChange;
});
final sessionProvider = Provider<Session?>((ref) {
final client = ref.watch(supabaseClientProvider);
return client.auth.currentSession;
});
final authControllerProvider = Provider<AuthController>((ref) {
final client = ref.watch(supabaseClientProvider);
return AuthController(client);
});
class AuthController {
AuthController(this._client);
final SupabaseClient _client;
Future<AuthResponse> signInWithPassword({
required String email,
required String password,
}) {
return _client.auth.signInWithPassword(email: email, password: password);
}
Future<AuthResponse> signUp({
required String email,
required String password,
String? fullName,
List<String>? officeIds,
}) {
return _client.auth.signUp(
email: email,
password: password,
data: {
if (fullName != null && fullName.trim().isNotEmpty)
'full_name': fullName.trim(),
if (officeIds != null && officeIds.isNotEmpty) 'office_ids': officeIds,
},
);
}
Future<void> signInWithGoogle({String? redirectTo}) {
return _client.auth.signInWithOAuth(
OAuthProvider.google,
redirectTo: redirectTo,
);
}
Future<void> signInWithMeta({String? redirectTo}) {
return _client.auth.signInWithOAuth(
OAuthProvider.facebook,
redirectTo: redirectTo,
);
}
Future<void> signOut() {
return _client.auth.signOut();
}
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/notification_item.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
final notificationsProvider = StreamProvider<List<NotificationItem>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) {
return const Stream.empty();
}
final client = ref.watch(supabaseClientProvider);
return client
.from('notifications')
.stream(primaryKey: ['id'])
.eq('user_id', userId)
.order('created_at', ascending: false)
.map((rows) => rows.map(NotificationItem.fromMap).toList());
});
final unreadNotificationsCountProvider = Provider<int>((ref) {
final notificationsAsync = ref.watch(notificationsProvider);
return notificationsAsync.maybeWhen(
data: (items) => items.where((item) => item.isUnread).length,
orElse: () => 0,
);
});
final notificationsControllerProvider = Provider<NotificationsController>((
ref,
) {
final client = ref.watch(supabaseClientProvider);
return NotificationsController(client);
});
class NotificationsController {
NotificationsController(this._client);
final SupabaseClient _client;
Future<void> createMentionNotifications({
required List<String> userIds,
required String actorId,
required int messageId,
String? ticketId,
String? taskId,
}) async {
if (userIds.isEmpty) return;
if ((ticketId == null || ticketId.isEmpty) &&
(taskId == null || taskId.isEmpty)) {
return;
}
final rows = userIds
.map(
(userId) => {
'user_id': userId,
'actor_id': actorId,
'ticket_id': ticketId,
'task_id': taskId,
'message_id': messageId,
'type': 'mention',
},
)
.toList();
await _client.from('notifications').insert(rows);
}
Future<void> markRead(String id) async {
await _client
.from('notifications')
.update({'read_at': DateTime.now().toUtc().toIso8601String()})
.eq('id', id);
}
Future<void> markReadForTicket(String ticketId) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) return;
await _client
.from('notifications')
.update({'read_at': DateTime.now().toUtc().toIso8601String()})
.eq('ticket_id', ticketId)
.eq('user_id', userId)
.filter('read_at', 'is', null);
}
Future<void> markReadForTask(String taskId) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) return;
await _client
.from('notifications')
.update({'read_at': DateTime.now().toUtc().toIso8601String()})
.eq('task_id', taskId)
.eq('user_id', userId)
.filter('read_at', 'is', null);
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/profile.dart';
import 'auth_provider.dart';
import 'supabase_provider.dart';
final currentUserIdProvider = Provider<String?>((ref) {
final authState = ref.watch(authStateChangesProvider);
return authState.maybeWhen(
data: (state) => state.session?.user.id,
orElse: () => ref.watch(sessionProvider)?.user.id,
);
});
final currentProfileProvider = StreamProvider<Profile?>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) {
return const Stream.empty();
}
final client = ref.watch(supabaseClientProvider);
return client
.from('profiles')
.stream(primaryKey: ['id'])
.eq('id', userId)
.map((rows) => rows.isEmpty ? null : Profile.fromMap(rows.first));
});
final profilesProvider = StreamProvider<List<Profile>>((ref) {
final client = ref.watch(supabaseClientProvider);
return client
.from('profiles')
.stream(primaryKey: ['id'])
.order('full_name')
.map((rows) => rows.map(Profile.fromMap).toList());
});
final isAdminProvider = Provider<bool>((ref) {
final profileAsync = ref.watch(currentProfileProvider);
return profileAsync.maybeWhen(
data: (profile) => profile?.role == 'admin',
orElse: () => false,
);
});
+6
View File
@@ -0,0 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
final supabaseClientProvider = Provider<SupabaseClient>((ref) {
return Supabase.instance.client;
});
+235
View File
@@ -0,0 +1,235 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/task.dart';
import '../models/task_assignment.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'tickets_provider.dart';
import 'user_offices_provider.dart';
final tasksProvider = StreamProvider<List<Task>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final ticketsAsync = ref.watch(ticketsProvider);
final assignmentsAsync = ref.watch(userOfficesProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) {
return Stream.value(const <Task>[]);
}
final isGlobal =
profile.role == 'admin' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
if (isGlobal) {
return client
.from('tasks')
.stream(primaryKey: ['id'])
.order('queue_order', ascending: true)
.order('created_at')
.map((rows) => rows.map(Task.fromMap).toList());
}
final allowedTicketIds =
ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
<String>[];
final officeIds =
assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toSet()
.toList() ??
<String>[];
if (allowedTicketIds.isEmpty && officeIds.isEmpty) {
return Stream.value(const <Task>[]);
}
return client
.from('tasks')
.stream(primaryKey: ['id'])
.order('queue_order', ascending: true)
.order('created_at')
.map(
(rows) => rows.map(Task.fromMap).where((task) {
final matchesTicket =
task.ticketId != null && allowedTicketIds.contains(task.ticketId);
final matchesOffice =
task.officeId != null && officeIds.contains(task.officeId);
return matchesTicket || matchesOffice;
}).toList(),
);
});
final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) {
final client = ref.watch(supabaseClientProvider);
return client
.from('task_assignments')
.stream(primaryKey: ['task_id', 'user_id'])
.map((rows) => rows.map(TaskAssignment.fromMap).toList());
});
final taskAssignmentsControllerProvider = Provider<TaskAssignmentsController>((
ref,
) {
final client = ref.watch(supabaseClientProvider);
return TaskAssignmentsController(client);
});
final tasksControllerProvider = Provider<TasksController>((ref) {
final client = ref.watch(supabaseClientProvider);
return TasksController(client);
});
class TasksController {
TasksController(this._client);
final SupabaseClient _client;
Future<void> updateTaskStatus({
required String taskId,
required String status,
}) async {
await _client.from('tasks').update({'status': status}).eq('id', taskId);
}
Future<void> createTask({
required String title,
required String description,
required String officeId,
}) async {
final actorId = _client.auth.currentUser?.id;
final data = await _client
.from('tasks')
.insert({
'title': title,
'description': description,
'office_id': officeId,
})
.select('id')
.single();
final taskId = data['id'] as String?;
if (taskId == null) return;
unawaited(_notifyCreated(taskId: taskId, actorId: actorId));
}
Future<void> _notifyCreated({
required String taskId,
required String? actorId,
}) async {
try {
final recipients = await _fetchRoleUserIds(
roles: const ['dispatcher', 'it_staff'],
excludeUserId: actorId,
);
if (recipients.isEmpty) return;
final rows = recipients
.map(
(userId) => {
'user_id': userId,
'actor_id': actorId,
'task_id': taskId,
'type': 'created',
},
)
.toList();
await _client.from('notifications').insert(rows);
} catch (_) {
return;
}
}
Future<List<String>> _fetchRoleUserIds({
required List<String> roles,
required String? excludeUserId,
}) async {
try {
final data = await _client
.from('profiles')
.select('id, role')
.inFilter('role', roles);
final rows = data as List<dynamic>;
final ids = rows
.map((row) => row['id'] as String?)
.whereType<String>()
.where((id) => id.isNotEmpty && id != excludeUserId)
.toList();
return ids;
} catch (_) {
return [];
}
}
}
class TaskAssignmentsController {
TaskAssignmentsController(this._client);
final SupabaseClient _client;
Future<void> replaceAssignments({
required String taskId,
required String? ticketId,
required List<String> newUserIds,
required List<String> currentUserIds,
}) async {
final nextIds = newUserIds.toSet();
final currentIds = currentUserIds.toSet();
final toAdd = nextIds.difference(currentIds).toList();
final toRemove = currentIds.difference(nextIds).toList();
if (toAdd.isNotEmpty) {
final rows = toAdd
.map((userId) => {'task_id': taskId, 'user_id': userId})
.toList();
await _client.from('task_assignments').insert(rows);
await _notifyAssigned(taskId: taskId, ticketId: ticketId, userIds: toAdd);
}
if (toRemove.isNotEmpty) {
await _client
.from('task_assignments')
.delete()
.eq('task_id', taskId)
.inFilter('user_id', toRemove);
}
}
Future<void> _notifyAssigned({
required String taskId,
required String? ticketId,
required List<String> userIds,
}) async {
if (userIds.isEmpty) return;
try {
final actorId = _client.auth.currentUser?.id;
final rows = userIds
.map(
(userId) => {
'user_id': userId,
'actor_id': actorId,
'task_id': taskId,
'ticket_id': ticketId,
'type': 'assignment',
},
)
.toList();
await _client.from('notifications').insert(rows);
} catch (_) {
return;
}
}
Future<void> removeAssignment({
required String taskId,
required String userId,
}) async {
await _client
.from('task_assignments')
.delete()
.eq('task_id', taskId)
.eq('user_id', userId);
}
}
+248
View File
@@ -0,0 +1,248 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/office.dart';
import '../models/ticket.dart';
import '../models/ticket_message.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'user_offices_provider.dart';
final officesProvider = StreamProvider<List<Office>>((ref) {
final client = ref.watch(supabaseClientProvider);
return client
.from('offices')
.stream(primaryKey: ['id'])
.order('name')
.map((rows) => rows.map(Office.fromMap).toList());
});
final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client.from('offices').select().order('name');
return (rows as List<dynamic>)
.map((row) => Office.fromMap(row as Map<String, dynamic>))
.toList();
});
final officesControllerProvider = Provider<OfficesController>((ref) {
final client = ref.watch(supabaseClientProvider);
return OfficesController(client);
});
final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final assignmentsAsync = ref.watch(userOfficesProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) {
return Stream.value(const <Ticket>[]);
}
final isGlobal =
profile.role == 'admin' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
if (isGlobal) {
return client
.from('tickets')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false)
.map((rows) => rows.map(Ticket.fromMap).toList());
}
final officeIds =
assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toSet()
.toList() ??
<String>[];
if (officeIds.isEmpty) {
return Stream.value(const <Ticket>[]);
}
return client
.from('tickets')
.stream(primaryKey: ['id'])
.inFilter('office_id', officeIds)
.order('created_at', ascending: false)
.map((rows) => rows.map(Ticket.fromMap).toList());
});
final ticketMessagesProvider =
StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('ticket_messages')
.stream(primaryKey: ['id'])
.eq('ticket_id', ticketId)
.order('created_at', ascending: false)
.map((rows) => rows.map(TicketMessage.fromMap).toList());
});
final ticketMessagesAllProvider = StreamProvider<List<TicketMessage>>((ref) {
final client = ref.watch(supabaseClientProvider);
return client
.from('ticket_messages')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false)
.map((rows) => rows.map(TicketMessage.fromMap).toList());
});
final taskMessagesProvider = StreamProvider.family<List<TicketMessage>, String>(
(ref, taskId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('ticket_messages')
.stream(primaryKey: ['id'])
.eq('task_id', taskId)
.order('created_at', ascending: false)
.map((rows) => rows.map(TicketMessage.fromMap).toList());
},
);
final ticketsControllerProvider = Provider<TicketsController>((ref) {
final client = ref.watch(supabaseClientProvider);
return TicketsController(client);
});
class TicketsController {
TicketsController(this._client);
final SupabaseClient _client;
Future<void> createTicket({
required String subject,
required String description,
required String officeId,
}) async {
final actorId = _client.auth.currentUser?.id;
final data = await _client
.from('tickets')
.insert({
'subject': subject,
'description': description,
'office_id': officeId,
'creator_id': _client.auth.currentUser?.id,
})
.select('id')
.single();
final ticketId = data['id'] as String?;
if (ticketId == null) return;
unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId));
}
Future<void> _notifyCreated({
required String ticketId,
required String? actorId,
}) async {
try {
final recipients = await _fetchRoleUserIds(
roles: const ['dispatcher', 'it_staff'],
excludeUserId: actorId,
);
if (recipients.isEmpty) return;
final rows = recipients
.map(
(userId) => {
'user_id': userId,
'actor_id': actorId,
'ticket_id': ticketId,
'type': 'created',
},
)
.toList();
await _client.from('notifications').insert(rows);
} catch (_) {
return;
}
}
Future<List<String>> _fetchRoleUserIds({
required List<String> roles,
required String? excludeUserId,
}) async {
try {
final data = await _client
.from('profiles')
.select('id, role')
.inFilter('role', roles);
final rows = data as List<dynamic>;
final ids = rows
.map((row) => row['id'] as String?)
.whereType<String>()
.where((id) => id.isNotEmpty && id != excludeUserId)
.toList();
return ids;
} catch (_) {
return [];
}
}
Future<TicketMessage> sendTicketMessage({
required String ticketId,
required String content,
}) async {
final data = await _client
.from('ticket_messages')
.insert({
'ticket_id': ticketId,
'content': content,
'sender_id': _client.auth.currentUser?.id,
})
.select()
.single();
return TicketMessage.fromMap(data);
}
Future<TicketMessage> sendTaskMessage({
required String taskId,
required String? ticketId,
required String content,
}) async {
final payload = <String, dynamic>{
'task_id': taskId,
'content': content,
'sender_id': _client.auth.currentUser?.id,
};
if (ticketId != null) {
payload['ticket_id'] = ticketId;
}
final data = await _client
.from('ticket_messages')
.insert(payload)
.select()
.single();
return TicketMessage.fromMap(data);
}
Future<void> updateTicketStatus({
required String ticketId,
required String status,
}) async {
await _client.from('tickets').update({'status': status}).eq('id', ticketId);
}
}
class OfficesController {
OfficesController(this._client);
final SupabaseClient _client;
Future<void> createOffice({required String name}) async {
await _client.from('offices').insert({'name': name});
}
Future<void> updateOffice({required String id, required String name}) async {
await _client.from('offices').update({'name': name}).eq('id', id);
}
Future<void> deleteOffice({required String id}) async {
await _client.from('offices').delete().eq('id', id);
}
}
+152
View File
@@ -0,0 +1,152 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'supabase_provider.dart';
class TypingIndicatorState {
const TypingIndicatorState({
required this.userIds,
required this.channelStatus,
required this.lastPayload,
});
final Set<String> userIds;
final String channelStatus;
final Map<String, dynamic> lastPayload;
TypingIndicatorState copyWith({
Set<String>? userIds,
String? channelStatus,
Map<String, dynamic>? lastPayload,
}) {
return TypingIndicatorState(
userIds: userIds ?? this.userIds,
channelStatus: channelStatus ?? this.channelStatus,
lastPayload: lastPayload ?? this.lastPayload,
);
}
}
final typingIndicatorProvider = StateNotifierProvider.autoDispose
.family<TypingIndicatorController, TypingIndicatorState, String>((
ref,
ticketId,
) {
final client = ref.watch(supabaseClientProvider);
final controller = TypingIndicatorController(client, ticketId);
ref.onDispose(controller.dispose);
return controller;
});
class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
TypingIndicatorController(this._client, this._ticketId)
: super(
const TypingIndicatorState(
userIds: {},
channelStatus: 'init',
lastPayload: {},
),
) {
_initChannel();
}
final SupabaseClient _client;
final String _ticketId;
RealtimeChannel? _channel;
Timer? _typingTimer;
final Map<String, Timer> _remoteTimeouts = {};
void _initChannel() {
final channel = _client.channel('typing:$_ticketId');
channel.onBroadcast(
event: 'typing',
callback: (payload) {
final Map<String, dynamic> data = _extractPayload(payload);
final userId = data['user_id'] as String?;
final rawType = data['type']?.toString();
final currentUserId = _client.auth.currentUser?.id;
state = state.copyWith(lastPayload: data);
if (userId == null || userId == currentUserId) {
return;
}
if (rawType == 'stop') {
_clearRemoteTyping(userId);
return;
}
_markRemoteTyping(userId);
},
);
channel.subscribe((status, error) {
state = state.copyWith(channelStatus: status.name);
});
_channel = channel;
}
Map<String, dynamic> _extractPayload(dynamic payload) {
if (payload is Map<String, dynamic>) {
final inner = payload['payload'];
if (inner is Map<String, dynamic>) {
return inner;
}
return payload;
}
final dynamic inner = payload.payload;
if (inner is Map<String, dynamic>) {
return inner;
}
return <String, dynamic>{};
}
void userTyping() {
if (_client.auth.currentUser?.id == null) return;
_sendTypingEvent('start');
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(milliseconds: 150), () {
_sendTypingEvent('stop');
});
}
void stopTyping() {
_typingTimer?.cancel();
_sendTypingEvent('stop');
}
void _markRemoteTyping(String userId) {
final updated = {...state.userIds, userId};
state = state.copyWith(userIds: updated);
_remoteTimeouts[userId]?.cancel();
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () {
_clearRemoteTyping(userId);
});
}
void _clearRemoteTyping(String userId) {
final updated = {...state.userIds}..remove(userId);
state = state.copyWith(userIds: updated);
_remoteTimeouts[userId]?.cancel();
_remoteTimeouts.remove(userId);
}
void _sendTypingEvent(String type) {
final userId = _client.auth.currentUser?.id;
if (userId == null || _channel == null) return;
_channel!.sendBroadcastMessage(
event: 'typing',
payload: {'user_id': userId, 'type': type},
);
}
@override
void dispose() {
stopTyping();
_typingTimer?.cancel();
for (final timer in _remoteTimeouts.values) {
timer.cancel();
}
_remoteTimeouts.clear();
_channel?.unsubscribe();
super.dispose();
}
}
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/user_office.dart';
import 'supabase_provider.dart';
final userOfficesProvider = StreamProvider<List<UserOffice>>((ref) {
final client = ref.watch(supabaseClientProvider);
return client
.from('user_offices')
.stream(primaryKey: ['user_id', 'office_id'])
.order('created_at')
.map((rows) => rows.map(UserOffice.fromMap).toList());
});
final userOfficesControllerProvider = Provider<UserOfficesController>((ref) {
final client = ref.watch(supabaseClientProvider);
return UserOfficesController(client);
});
class UserOfficesController {
UserOfficesController(this._client);
final SupabaseClient _client;
Future<void> assignUserOffice({
required String userId,
required String officeId,
}) async {
await _client.from('user_offices').insert({
'user_id': userId,
'office_id': officeId,
});
}
Future<void> removeUserOffice({
required String userId,
required String officeId,
}) async {
await _client
.from('user_offices')
.delete()
.eq('user_id', userId)
.eq('office_id', officeId);
}
}