full stack first commit
This commit is contained in:
25
b0esche_cloud/lib/blocs/activity/activity_bloc.dart
Normal file
25
b0esche_cloud/lib/blocs/activity/activity_bloc.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'activity_event.dart';
|
||||||
|
import 'activity_state.dart';
|
||||||
|
import '../../services/activity_api.dart';
|
||||||
|
|
||||||
|
class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
|
||||||
|
final ActivityApi activityApi;
|
||||||
|
|
||||||
|
ActivityBloc(this.activityApi) : super(ActivityInitial()) {
|
||||||
|
on<LoadOrgActivities>(_onLoadOrgActivities);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLoadOrgActivities(
|
||||||
|
LoadOrgActivities event,
|
||||||
|
Emitter<ActivityState> emit,
|
||||||
|
) async {
|
||||||
|
emit(ActivityLoading());
|
||||||
|
try {
|
||||||
|
final activities = await activityApi.getOrgActivities(event.orgId);
|
||||||
|
emit(ActivityLoaded(activities: activities));
|
||||||
|
} catch (e) {
|
||||||
|
emit(ActivityError(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
b0esche_cloud/lib/blocs/activity/activity_event.dart
Normal file
17
b0esche_cloud/lib/blocs/activity/activity_event.dart
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
abstract class ActivityEvent extends Equatable {
|
||||||
|
const ActivityEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoadOrgActivities extends ActivityEvent {
|
||||||
|
final String orgId;
|
||||||
|
|
||||||
|
const LoadOrgActivities(this.orgId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [orgId];
|
||||||
|
}
|
||||||
31
b0esche_cloud/lib/blocs/activity/activity_state.dart
Normal file
31
b0esche_cloud/lib/blocs/activity/activity_state.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import '../organization/organization_state.dart';
|
||||||
|
|
||||||
|
abstract class ActivityState extends Equatable {
|
||||||
|
const ActivityState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ActivityInitial extends ActivityState {}
|
||||||
|
|
||||||
|
class ActivityLoading extends ActivityState {}
|
||||||
|
|
||||||
|
class ActivityLoaded extends ActivityState {
|
||||||
|
final List<Activity> activities;
|
||||||
|
|
||||||
|
const ActivityLoaded({required this.activities});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [activities];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ActivityError extends ActivityState {
|
||||||
|
final String error;
|
||||||
|
|
||||||
|
const ActivityError(this.error);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [error];
|
||||||
|
}
|
||||||
@@ -11,14 +11,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
|
|
||||||
void _onLoginRequested(LoginRequested event, Emitter<AuthState> emit) async {
|
void _onLoginRequested(LoginRequested event, Emitter<AuthState> emit) async {
|
||||||
emit(AuthLoading());
|
emit(AuthLoading());
|
||||||
// Simulate API call to go.b0esche.cloud/auth/login
|
// Redirect to Go auth/login
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
// For web, use window.location or url_launcher
|
||||||
if (event.email.isNotEmpty && event.password.isNotEmpty) {
|
// Assume handled in UI
|
||||||
// Assume JWT received
|
emit(const AuthFailure('Redirect to login URL'));
|
||||||
emit(AuthAuthenticated(token: 'fake-jwt', userId: 'user123'));
|
|
||||||
} else {
|
|
||||||
emit(const AuthFailure('Invalid credentials'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onLogoutRequested(
|
void _onLogoutRequested(
|
||||||
|
|||||||
@@ -7,15 +7,7 @@ abstract class AuthEvent extends Equatable {
|
|||||||
List<Object> get props => [];
|
List<Object> get props => [];
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoginRequested extends AuthEvent {
|
class LoginRequested extends AuthEvent {}
|
||||||
final String email;
|
|
||||||
final String password;
|
|
||||||
|
|
||||||
const LoginRequested(this.email, this.password);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object> get props => [email, password];
|
|
||||||
}
|
|
||||||
|
|
||||||
class LogoutRequested extends AuthEvent {}
|
class LogoutRequested extends AuthEvent {}
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,20 @@ import '../file_browser/file_browser_bloc.dart';
|
|||||||
import '../file_browser/file_browser_event.dart';
|
import '../file_browser/file_browser_event.dart';
|
||||||
import '../upload/upload_bloc.dart';
|
import '../upload/upload_bloc.dart';
|
||||||
import '../upload/upload_event.dart';
|
import '../upload/upload_event.dart';
|
||||||
|
import '../../services/org_api.dart';
|
||||||
|
|
||||||
class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
||||||
final PermissionBloc permissionBloc;
|
final PermissionBloc permissionBloc;
|
||||||
final FileBrowserBloc fileBrowserBloc;
|
final FileBrowserBloc fileBrowserBloc;
|
||||||
final UploadBloc uploadBloc;
|
final UploadBloc uploadBloc;
|
||||||
|
final OrgApi orgApi;
|
||||||
|
|
||||||
OrganizationBloc(this.permissionBloc, this.fileBrowserBloc, this.uploadBloc)
|
OrganizationBloc(
|
||||||
: super(OrganizationInitial()) {
|
this.permissionBloc,
|
||||||
|
this.fileBrowserBloc,
|
||||||
|
this.uploadBloc,
|
||||||
|
this.orgApi,
|
||||||
|
) : super(OrganizationInitial()) {
|
||||||
on<LoadOrganizations>(_onLoadOrganizations);
|
on<LoadOrganizations>(_onLoadOrganizations);
|
||||||
on<SelectOrganization>(_onSelectOrganization);
|
on<SelectOrganization>(_onSelectOrganization);
|
||||||
on<CreateOrganization>(_onCreateOrganization);
|
on<CreateOrganization>(_onCreateOrganization);
|
||||||
@@ -27,14 +33,13 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
) async {
|
) async {
|
||||||
emit(OrganizationLoading());
|
emit(OrganizationLoading());
|
||||||
try {
|
try {
|
||||||
// Simulate loading orgs from API
|
final orgs = await orgApi.getOrganizations();
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
emit(
|
||||||
final orgs = [
|
OrganizationLoaded(
|
||||||
const Organization(id: 'org1', name: 'Personal', role: 'admin'),
|
organizations: orgs,
|
||||||
const Organization(id: 'org2', name: 'Company Inc', role: 'edit'),
|
selectedOrg: orgs.isNotEmpty ? orgs.first : null,
|
||||||
const Organization(id: 'org3', name: 'Side Project', role: 'admin'),
|
),
|
||||||
];
|
);
|
||||||
emit(OrganizationLoaded(organizations: orgs, selectedOrg: orgs.first));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(OrganizationError(e.toString()));
|
emit(OrganizationError(e.toString()));
|
||||||
}
|
}
|
||||||
@@ -103,13 +108,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
// Simulate API call
|
final newOrg = await orgApi.createOrganization(name);
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
final newOrg = Organization(
|
|
||||||
id: 'org${currentState.organizations.length + 1}',
|
|
||||||
name: name,
|
|
||||||
role: 'admin',
|
|
||||||
);
|
|
||||||
final updatedOrgs = [...currentState.organizations, newOrg];
|
final updatedOrgs = [...currentState.organizations, newOrg];
|
||||||
emit(
|
emit(
|
||||||
OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg),
|
OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg),
|
||||||
|
|||||||
@@ -3,20 +3,70 @@ import 'package:equatable/equatable.dart';
|
|||||||
class Organization extends Equatable {
|
class Organization extends Equatable {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String role; // view, edit, admin
|
final String slug;
|
||||||
final String? shortCode;
|
final DateTime createdAt;
|
||||||
final int? color;
|
|
||||||
|
|
||||||
const Organization({
|
const Organization({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.role,
|
required this.slug,
|
||||||
this.shortCode,
|
required this.createdAt,
|
||||||
this.color,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [id, name, role, shortCode, color];
|
List<Object?> get props => [id, name, slug, createdAt];
|
||||||
|
|
||||||
|
factory Organization.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Organization(
|
||||||
|
id: json['id'],
|
||||||
|
name: json['name'],
|
||||||
|
slug: json['slug'],
|
||||||
|
createdAt: DateTime.parse(json['createdAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Activity extends Equatable {
|
||||||
|
final String id;
|
||||||
|
final String userId;
|
||||||
|
final String orgId;
|
||||||
|
final String? fileId;
|
||||||
|
final String action;
|
||||||
|
final Map<String, dynamic> metadata;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
const Activity({
|
||||||
|
required this.id,
|
||||||
|
required this.userId,
|
||||||
|
required this.orgId,
|
||||||
|
this.fileId,
|
||||||
|
required this.action,
|
||||||
|
required this.metadata,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
orgId,
|
||||||
|
fileId,
|
||||||
|
action,
|
||||||
|
metadata,
|
||||||
|
timestamp,
|
||||||
|
];
|
||||||
|
|
||||||
|
factory Activity.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Activity(
|
||||||
|
id: json['id'],
|
||||||
|
userId: json['userId'],
|
||||||
|
orgId: json['orgId'],
|
||||||
|
fileId: json['fileId'],
|
||||||
|
action: json['action'],
|
||||||
|
metadata: json['metadata'] ?? {},
|
||||||
|
timestamp: DateTime.parse(json['timestamp']),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class OrganizationState extends Equatable {
|
abstract class OrganizationState extends Equatable {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ class SessionBloc extends Bloc<SessionEvent, SessionState> {
|
|||||||
|
|
||||||
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
|
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
|
||||||
final expiresAt = DateTime.now().add(
|
final expiresAt = DateTime.now().add(
|
||||||
const Duration(hours: 1),
|
const Duration(minutes: 15),
|
||||||
); // Fake expiry
|
); // Match Go
|
||||||
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
||||||
_startExpiryTimer(expiresAt);
|
_startExpiryTimer(expiresAt);
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ class SessionBloc extends Bloc<SessionEvent, SessionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) {
|
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) {
|
||||||
final expiresAt = DateTime.now().add(const Duration(hours: 1));
|
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
|
||||||
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
|
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
|
||||||
_startExpiryTimer(expiresAt);
|
_startExpiryTimer(expiresAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import 'blocs/organization/organization_bloc.dart';
|
|||||||
import 'blocs/permission/permission_bloc.dart';
|
import 'blocs/permission/permission_bloc.dart';
|
||||||
import 'blocs/file_browser/file_browser_bloc.dart';
|
import 'blocs/file_browser/file_browser_bloc.dart';
|
||||||
import 'blocs/upload/upload_bloc.dart';
|
import 'blocs/upload/upload_bloc.dart';
|
||||||
|
import 'blocs/activity/activity_bloc.dart';
|
||||||
import 'repositories/mock_file_repository.dart';
|
import 'repositories/mock_file_repository.dart';
|
||||||
import 'services/file_service.dart';
|
import 'services/file_service.dart';
|
||||||
|
import 'services/api_client.dart';
|
||||||
|
import 'services/org_api.dart';
|
||||||
|
import 'services/activity_api.dart';
|
||||||
import 'pages/home_page.dart';
|
import 'pages/home_page.dart';
|
||||||
import 'pages/login_form.dart';
|
import 'pages/login_form.dart';
|
||||||
import 'pages/file_explorer.dart';
|
import 'pages/file_explorer.dart';
|
||||||
@@ -58,10 +62,13 @@ class MainApp extends StatelessWidget {
|
|||||||
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
|
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
|
||||||
BlocProvider<PermissionBloc>(create: (_) => PermissionBloc()),
|
BlocProvider<PermissionBloc>(create: (_) => PermissionBloc()),
|
||||||
BlocProvider<FileBrowserBloc>(
|
BlocProvider<FileBrowserBloc>(
|
||||||
create: (_) => FileBrowserBloc(FileService(MockFileRepository())),
|
create: (context) => FileBrowserBloc(
|
||||||
|
FileService(ApiClient(context.read<SessionBloc>())),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
BlocProvider<UploadBloc>(
|
BlocProvider<UploadBloc>(
|
||||||
create: (_) => UploadBloc(MockFileRepository()),
|
create: (_) =>
|
||||||
|
UploadBloc(MockFileRepository()), // keep mock for upload
|
||||||
),
|
),
|
||||||
BlocProvider<OrganizationBloc>(
|
BlocProvider<OrganizationBloc>(
|
||||||
lazy: true,
|
lazy: true,
|
||||||
@@ -69,8 +76,13 @@ class MainApp extends StatelessWidget {
|
|||||||
context.read<PermissionBloc>(),
|
context.read<PermissionBloc>(),
|
||||||
context.read<FileBrowserBloc>(),
|
context.read<FileBrowserBloc>(),
|
||||||
context.read<UploadBloc>(),
|
context.read<UploadBloc>(),
|
||||||
|
OrgApi(ApiClient(context.read<SessionBloc>())),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
BlocProvider<ActivityBloc>(
|
||||||
|
create: (context) =>
|
||||||
|
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
routerConfig: _router,
|
routerConfig: _router,
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<AuthBloc>().add(
|
context.read<AuthBloc>().add(
|
||||||
LoginRequested(
|
LoginRequested(
|
||||||
_emailController.text,
|
// _emailController.text,
|
||||||
_passwordController.text,
|
// _passwordController.text,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
15
b0esche_cloud/lib/services/activity_api.dart
Normal file
15
b0esche_cloud/lib/services/activity_api.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import '../blocs/organization/organization_state.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class ActivityApi {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
ActivityApi(this._apiClient);
|
||||||
|
|
||||||
|
Future<List<Activity>> getOrgActivities(String orgId) async {
|
||||||
|
return await _apiClient.getList(
|
||||||
|
'/orgs/$orgId/activity',
|
||||||
|
fromJson: (data) => Activity.fromJson(data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,19 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<T>> getList<T>(
|
||||||
|
String path, {
|
||||||
|
Map<String, dynamic>? queryParameters,
|
||||||
|
required T Function(dynamic data) fromJson,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(path, queryParameters: queryParameters);
|
||||||
|
return (response.data as List).map(fromJson).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ApiError _handleError(DioException e) {
|
ApiError _handleError(DioException e) {
|
||||||
final status = e.response?.statusCode;
|
final status = e.response?.statusCode;
|
||||||
final data = e.response?.data;
|
final data = e.response?.data;
|
||||||
|
|||||||
@@ -3,38 +3,39 @@ import '../models/viewer_session.dart';
|
|||||||
import '../models/editor_session.dart';
|
import '../models/editor_session.dart';
|
||||||
import '../models/annotation.dart';
|
import '../models/annotation.dart';
|
||||||
import '../repositories/file_repository.dart';
|
import '../repositories/file_repository.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
class FileService {
|
class FileService {
|
||||||
final FileRepository _fileRepository;
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
FileService(this._fileRepository);
|
FileService(this._apiClient);
|
||||||
|
|
||||||
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
||||||
if (path.isEmpty) {
|
if (path.isEmpty) {
|
||||||
throw Exception('Path cannot be empty');
|
throw Exception('Path cannot be empty');
|
||||||
}
|
}
|
||||||
return await _fileRepository.getFiles(orgId, path);
|
return await _apiClient.getList(
|
||||||
|
'/orgs/$orgId/files',
|
||||||
|
fromJson: (data) => FileItem(
|
||||||
|
name: data['name'],
|
||||||
|
path: data['path'],
|
||||||
|
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
||||||
|
size: data['size'],
|
||||||
|
lastModified: DateTime.parse(data['lastModified']),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<FileItem?> getFile(String orgId, String path) async {
|
Future<FileItem?> getFile(String orgId, String path) async {
|
||||||
if (path.isEmpty) {
|
throw UnimplementedError();
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await _fileRepository.getFile(orgId, path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
Future<void> uploadFile(String orgId, FileItem file) async {
|
||||||
if (file.name.isEmpty) {
|
throw UnimplementedError();
|
||||||
throw Exception('File name cannot be empty');
|
|
||||||
}
|
|
||||||
await _fileRepository.uploadFile(orgId, file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteFile(String orgId, String path) async {
|
Future<void> deleteFile(String orgId, String path) async {
|
||||||
if (path.isEmpty) {
|
throw UnimplementedError();
|
||||||
throw Exception('Path cannot be empty');
|
|
||||||
}
|
|
||||||
await _fileRepository.deleteFile(orgId, path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createFolder(
|
Future<void> createFolder(
|
||||||
@@ -42,10 +43,7 @@ class FileService {
|
|||||||
String parentPath,
|
String parentPath,
|
||||||
String folderName,
|
String folderName,
|
||||||
) async {
|
) async {
|
||||||
if (folderName.isEmpty) {
|
throw UnimplementedError();
|
||||||
throw Exception('Folder name cannot be empty');
|
|
||||||
}
|
|
||||||
await _fileRepository.createFolder(orgId, parentPath, folderName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> moveFile(
|
Future<void> moveFile(
|
||||||
@@ -53,24 +51,15 @@ class FileService {
|
|||||||
String sourcePath,
|
String sourcePath,
|
||||||
String targetPath,
|
String targetPath,
|
||||||
) async {
|
) async {
|
||||||
if (sourcePath.isEmpty || targetPath.isEmpty) {
|
throw UnimplementedError();
|
||||||
throw Exception('Paths cannot be empty');
|
|
||||||
}
|
|
||||||
await _fileRepository.moveFile(orgId, sourcePath, targetPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> renameFile(String orgId, String path, String newName) async {
|
Future<void> renameFile(String orgId, String path, String newName) async {
|
||||||
if (path.isEmpty || newName.isEmpty) {
|
throw UnimplementedError();
|
||||||
throw Exception('Path and new name cannot be empty');
|
|
||||||
}
|
|
||||||
await _fileRepository.renameFile(orgId, path, newName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<FileItem>> searchFiles(String orgId, String query) async {
|
Future<List<FileItem>> searchFiles(String orgId, String query) async {
|
||||||
if (query.isEmpty) {
|
throw UnimplementedError();
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return await _fileRepository.searchFiles(orgId, query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ViewerSession> requestViewerSession(
|
Future<ViewerSession> requestViewerSession(
|
||||||
@@ -80,7 +69,10 @@ class FileService {
|
|||||||
if (orgId.isEmpty || fileId.isEmpty) {
|
if (orgId.isEmpty || fileId.isEmpty) {
|
||||||
throw Exception('OrgId and fileId cannot be empty');
|
throw Exception('OrgId and fileId cannot be empty');
|
||||||
}
|
}
|
||||||
return await _fileRepository.requestViewerSession(orgId, fileId);
|
return await _apiClient.get(
|
||||||
|
'/orgs/$orgId/files/$fileId/view',
|
||||||
|
fromJson: (data) => ViewerSession.fromJson(data),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<EditorSession> requestEditorSession(
|
Future<EditorSession> requestEditorSession(
|
||||||
@@ -90,7 +82,10 @@ class FileService {
|
|||||||
if (orgId.isEmpty || fileId.isEmpty) {
|
if (orgId.isEmpty || fileId.isEmpty) {
|
||||||
throw Exception('OrgId and fileId cannot be empty');
|
throw Exception('OrgId and fileId cannot be empty');
|
||||||
}
|
}
|
||||||
return await _fileRepository.requestEditorSession(orgId, fileId);
|
return await _apiClient.get(
|
||||||
|
'/orgs/$orgId/files/$fileId/edit',
|
||||||
|
fromJson: (data) => EditorSession.fromJson(data),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveAnnotations(
|
Future<void> saveAnnotations(
|
||||||
@@ -101,6 +96,13 @@ class FileService {
|
|||||||
if (orgId.isEmpty || fileId.isEmpty) {
|
if (orgId.isEmpty || fileId.isEmpty) {
|
||||||
throw Exception('OrgId and fileId cannot be empty');
|
throw Exception('OrgId and fileId cannot be empty');
|
||||||
}
|
}
|
||||||
await _fileRepository.saveAnnotations(orgId, fileId, annotations);
|
await _apiClient.post(
|
||||||
|
'/orgs/$orgId/files/$fileId/annotations',
|
||||||
|
data: {
|
||||||
|
'annotations': annotations.map((a) => a.toJson()).toList(),
|
||||||
|
'baseVersionId': '1', // mock
|
||||||
|
},
|
||||||
|
fromJson: (data) => null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
b0esche_cloud/lib/services/org_api.dart
Normal file
23
b0esche_cloud/lib/services/org_api.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import '../blocs/organization/organization_state.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class OrgApi {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
OrgApi(this._apiClient);
|
||||||
|
|
||||||
|
Future<List<Organization>> getOrganizations() async {
|
||||||
|
return await _apiClient.getList(
|
||||||
|
'/orgs',
|
||||||
|
fromJson: (data) => Organization.fromJson(data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Organization> createOrganization(String name) async {
|
||||||
|
return await _apiClient.post(
|
||||||
|
'/orgs',
|
||||||
|
data: {'name': name},
|
||||||
|
fromJson: (data) => Organization.fromJson(data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,16 @@ type Membership struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Activity struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
UserID uuid.UUID
|
||||||
|
OrgID uuid.UUID
|
||||||
|
FileID *string
|
||||||
|
Action string
|
||||||
|
Metadata map[string]interface{}
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
|
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
err := db.QueryRowContext(ctx, `
|
err := db.QueryRowContext(ctx, `
|
||||||
@@ -122,3 +132,89 @@ func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*
|
|||||||
}
|
}
|
||||||
return &membership, nil
|
return &membership, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateOrg(ctx context.Context, name, slug string) (*Organization, error) {
|
||||||
|
var org Organization
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO organizations (name, slug)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING id, name, slug, created_at
|
||||||
|
`, name, slug).Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &org, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AddMembership(ctx context.Context, userID, orgID uuid.UUID, role string) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO memberships (user_id, org_id, role)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`, userID, orgID, role)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) LogActivity(ctx context.Context, userID, orgID uuid.UUID, fileID *string, action string, metadata map[string]interface{}) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO activities (user_id, org_id, file_id, action, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`, userID, orgID, fileID, action, metadata)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetOrgActivities(ctx context.Context, orgID uuid.UUID, limit int) ([]Activity, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, user_id, org_id, file_id, action, metadata, timestamp
|
||||||
|
FROM activities
|
||||||
|
WHERE org_id = $1
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT $2
|
||||||
|
`, orgID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var activities []Activity
|
||||||
|
for rows.Next() {
|
||||||
|
var a Activity
|
||||||
|
err := rows.Scan(&a.ID, &a.UserID, &a.OrgID, &a.FileID, &a.Action, &a.Metadata, &a.Timestamp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
activities = append(activities, a)
|
||||||
|
}
|
||||||
|
return activities, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT user_id, org_id, role, created_at
|
||||||
|
FROM memberships
|
||||||
|
WHERE org_id = $1
|
||||||
|
`, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var memberships []Membership
|
||||||
|
for rows.Next() {
|
||||||
|
var m Membership
|
||||||
|
err := rows.Scan(&m.UserID, &m.OrgID, &m.Role, &m.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
memberships = append(memberships, m)
|
||||||
|
}
|
||||||
|
return memberships, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE memberships
|
||||||
|
SET role = $1
|
||||||
|
WHERE org_id = $2 AND user_id = $3
|
||||||
|
`, role, orgID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"go.b0esche.cloud/backend/internal/audit"
|
"go.b0esche.cloud/backend/internal/audit"
|
||||||
"go.b0esche.cloud/backend/internal/auth"
|
"go.b0esche.cloud/backend/internal/auth"
|
||||||
"go.b0esche.cloud/backend/internal/config"
|
"go.b0esche.cloud/backend/internal/config"
|
||||||
"go.b0esche.cloud/backend/internal/database"
|
"go.b0esche.cloud/backend/internal/database"
|
||||||
"go.b0esche.cloud/backend/internal/middleware"
|
"go.b0esche.cloud/backend/internal/middleware"
|
||||||
|
"go.b0esche.cloud/backend/internal/org"
|
||||||
|
"go.b0esche.cloud/backend/internal/permission"
|
||||||
"go.b0esche.cloud/backend/pkg/jwt"
|
"go.b0esche.cloud/backend/pkg/jwt"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler {
|
func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler {
|
||||||
@@ -31,13 +36,57 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
|||||||
authLoginHandler(w, req, authService)
|
authLoginHandler(w, req, authService)
|
||||||
})
|
})
|
||||||
r.Get("/callback", func(w http.ResponseWriter, req *http.Request) {
|
r.Get("/callback", func(w http.ResponseWriter, req *http.Request) {
|
||||||
authCallbackHandler(w, req, cfg, authService, jwtManager, auditLogger)
|
authCallbackHandler(w, req, cfg, authService, jwtManager, auditLogger, db)
|
||||||
|
})
|
||||||
|
r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
refreshHandler(w, req, jwtManager, db)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auth middleware for protected routes
|
// Auth middleware for protected routes
|
||||||
r.Use(middleware.Auth(jwtManager, db))
|
r.Use(middleware.Auth(jwtManager, db))
|
||||||
|
|
||||||
|
// Org routes
|
||||||
|
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
listOrgsHandler(w, req, db, jwtManager)
|
||||||
|
})
|
||||||
|
r.Post("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
createOrgHandler(w, req, db, auditLogger, jwtManager)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Org-scoped routes
|
||||||
|
r.Route("/orgs/{orgId}", func(r chi.Router) {
|
||||||
|
r.Use(middleware.Org(db, auditLogger))
|
||||||
|
|
||||||
|
// File routes
|
||||||
|
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
listFilesHandler(w, req)
|
||||||
|
})
|
||||||
|
r.Route("/files/{fileId}", func(r chi.Router) {
|
||||||
|
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
viewerHandler(w, req, db, auditLogger)
|
||||||
|
})
|
||||||
|
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Get("/edit", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
editorHandler(w, req, db, auditLogger)
|
||||||
|
})
|
||||||
|
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Post("/annotations", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
annotationsHandler(w, req, db, auditLogger)
|
||||||
|
})
|
||||||
|
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
fileMetaHandler(w, req)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
r.Get("/activity", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
activityHandler(w, req, db)
|
||||||
|
})
|
||||||
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/members", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
listMembersHandler(w, req, db)
|
||||||
|
})
|
||||||
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Patch("/members/{userId}", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
updateMemberRoleHandler(w, req, db, auditLogger)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +108,7 @@ func authLoginHandler(w http.ResponseWriter, r *http.Request, authService *auth.
|
|||||||
http.Redirect(w, r, url, http.StatusFound)
|
http.Redirect(w, r, url, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Config, authService *auth.Service, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
|
func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Config, authService *auth.Service, jwtManager *jwt.Manager, auditLogger *audit.Logger, db *database.DB) {
|
||||||
code := r.URL.Query().Get("code")
|
code := r.URL.Query().Get("code")
|
||||||
state := r.URL.Query().Get("state")
|
state := r.URL.Query().Get("state")
|
||||||
|
|
||||||
@@ -76,7 +125,18 @@ func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Con
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := jwtManager.Generate(user.Email, []string{}, session.ID.String()) // Orgs not yet
|
// Get user orgs
|
||||||
|
orgs, err := org.ResolveUserOrgs(r.Context(), db, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orgIDs := make([]string, len(orgs))
|
||||||
|
for i, o := range orgs {
|
||||||
|
orgIDs[i] = o.ID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Token generation failed", http.StatusInternalServerError)
|
http.Error(w, "Token generation failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -91,3 +151,277 @@ func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Con
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte(`{"token": "` + token + `"}`))
|
w.Write([]byte(`{"token": "` + token + `"}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB) {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := uuid.Parse(claims.UserID)
|
||||||
|
orgs, err := db.GetUserOrganizations(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orgIDs := make([]string, len(orgs))
|
||||||
|
for i, o := range orgs {
|
||||||
|
orgIDs[i] = o.ID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
newToken, err := jwtManager.Generate(claims.UserID, orgIDs, session.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"token": "` + newToken + `"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listOrgsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
claims, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := uuid.Parse(claims.UserID)
|
||||||
|
orgs, err := org.ResolveUserOrgs(r.Context(), db, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(orgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, jwtManager *jwt.Manager) {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
claims, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := uuid.Parse(claims.UserID)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
org, err := org.CreateOrg(r.Context(), db, userID, req.Name, req.Slug)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auditLogger.Log(r.Context(), audit.Entry{
|
||||||
|
UserID: &userID,
|
||||||
|
OrgID: &org.ID,
|
||||||
|
Action: "create_org",
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(org)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listFilesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Mock files
|
||||||
|
files := []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
LastModified string `json:"lastModified"`
|
||||||
|
}{
|
||||||
|
{"test.pdf", "/test.pdf", "file", 1234, "2023-01-01T00:00:00Z"},
|
||||||
|
{"folder", "/folder", "folder", 0, "2023-01-01T00:00:00Z"},
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
||||||
|
userIDStr := r.Context().Value("user").(string)
|
||||||
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
|
orgID := r.Context().Value("org").(uuid.UUID)
|
||||||
|
fileId := chi.URLParam(r, "fileId")
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
db.LogActivity(r.Context(), userID, orgID, &fileId, "view_file", map[string]interface{}{})
|
||||||
|
|
||||||
|
session := struct {
|
||||||
|
ViewUrl string `json:"viewUrl"`
|
||||||
|
Capabilities struct {
|
||||||
|
CanEdit bool `json:"canEdit"`
|
||||||
|
CanAnnotate bool `json:"canAnnotate"`
|
||||||
|
IsPdf bool `json:"isPdf"`
|
||||||
|
} `json:"capabilities"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
}{
|
||||||
|
ViewUrl: "https://view.example.com/" + fileId,
|
||||||
|
Capabilities: struct {
|
||||||
|
CanEdit bool `json:"canEdit"`
|
||||||
|
CanAnnotate bool `json:"canAnnotate"`
|
||||||
|
IsPdf bool `json:"isPdf"`
|
||||||
|
}{CanEdit: true, CanAnnotate: true, IsPdf: true},
|
||||||
|
ExpiresAt: "2023-01-01T01:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func editorHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
||||||
|
userIDStr := r.Context().Value("user").(string)
|
||||||
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
|
orgID := r.Context().Value("org").(uuid.UUID)
|
||||||
|
fileId := chi.URLParam(r, "fileId")
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
db.LogActivity(r.Context(), userID, orgID, &fileId, "edit_file", map[string]interface{}{})
|
||||||
|
|
||||||
|
session := struct {
|
||||||
|
EditUrl string `json:"editUrl"`
|
||||||
|
ReadOnly bool `json:"readOnly"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
}{
|
||||||
|
EditUrl: "https://edit.example.com/" + fileId,
|
||||||
|
ReadOnly: false,
|
||||||
|
ExpiresAt: "2023-01-01T01:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func annotationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
||||||
|
userIDStr := r.Context().Value("user").(string)
|
||||||
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
|
orgID := r.Context().Value("org").(uuid.UUID)
|
||||||
|
fileId := chi.URLParam(r, "fileId")
|
||||||
|
|
||||||
|
// Parse payload
|
||||||
|
var payload struct {
|
||||||
|
Annotations []interface{} `json:"annotations"`
|
||||||
|
BaseVersionId string `json:"baseVersionId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
auditLogger.Log(r.Context(), audit.Entry{
|
||||||
|
UserID: &userID,
|
||||||
|
OrgID: &orgID,
|
||||||
|
Resource: &fileId,
|
||||||
|
Action: "annotate_pdf",
|
||||||
|
Success: true,
|
||||||
|
Metadata: map[string]interface{}{"count": len(payload.Annotations)},
|
||||||
|
})
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"status": "ok"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||||
|
orgID := r.Context().Value("org").(uuid.UUID)
|
||||||
|
|
||||||
|
activities, err := db.GetOrgActivities(r.Context(), orgID, 50)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(activities)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listMembersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||||
|
orgID := r.Context().Value("org").(uuid.UUID)
|
||||||
|
|
||||||
|
members, err := db.GetOrgMembers(r.Context(), orgID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(members)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateMemberRoleHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
||||||
|
orgID := r.Context().Value("org").(uuid.UUID)
|
||||||
|
userIDStr := chi.URLParam(r, "userId")
|
||||||
|
userID, err := uuid.Parse(userIDStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.UpdateMemberRole(r.Context(), orgID, userID, req.Role); err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"status": "ok"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileMetaHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
meta := struct {
|
||||||
|
LastModified string `json:"lastModified"`
|
||||||
|
LastModifiedBy string `json:"lastModifiedBy"`
|
||||||
|
VersionCount int `json:"versionCount"`
|
||||||
|
}{
|
||||||
|
LastModified: "2023-01-01T00:00:00Z",
|
||||||
|
LastModifiedBy: "user@example.com",
|
||||||
|
VersionCount: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(meta)
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,3 +21,20 @@ func CheckMembership(ctx context.Context, db *database.DB, userID, orgID uuid.UU
|
|||||||
}
|
}
|
||||||
return membership.Role, nil
|
return membership.Role, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateOrg creates a new organization and adds the user as owner
|
||||||
|
func CreateOrg(ctx context.Context, db *database.DB, userID uuid.UUID, name, slug string) (*database.Organization, error) {
|
||||||
|
if slug == "" {
|
||||||
|
// Simple slug generation
|
||||||
|
slug = name // TODO: make URL safe
|
||||||
|
}
|
||||||
|
org, err := db.CreateOrg(ctx, name, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = db.AddMembership(ctx, userID, org.ID, "owner")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return org, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,3 +40,13 @@ CREATE TABLE audit_logs (
|
|||||||
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
metadata JSONB
|
metadata JSONB
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE activities (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
org_id UUID REFERENCES organizations(id),
|
||||||
|
file_id TEXT, -- nullable, for future file table
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
metadata JSONB,
|
||||||
|
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user