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 {
|
||||
emit(AuthLoading());
|
||||
// Simulate API call to go.b0esche.cloud/auth/login
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (event.email.isNotEmpty && event.password.isNotEmpty) {
|
||||
// Assume JWT received
|
||||
emit(AuthAuthenticated(token: 'fake-jwt', userId: 'user123'));
|
||||
} else {
|
||||
emit(const AuthFailure('Invalid credentials'));
|
||||
}
|
||||
// Redirect to Go auth/login
|
||||
// For web, use window.location or url_launcher
|
||||
// Assume handled in UI
|
||||
emit(const AuthFailure('Redirect to login URL'));
|
||||
}
|
||||
|
||||
void _onLogoutRequested(
|
||||
|
||||
@@ -7,15 +7,7 @@ abstract class AuthEvent extends Equatable {
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class LoginRequested extends AuthEvent {
|
||||
final String email;
|
||||
final String password;
|
||||
|
||||
const LoginRequested(this.email, this.password);
|
||||
|
||||
@override
|
||||
List<Object> get props => [email, password];
|
||||
}
|
||||
class LoginRequested 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 '../upload/upload_bloc.dart';
|
||||
import '../upload/upload_event.dart';
|
||||
import '../../services/org_api.dart';
|
||||
|
||||
class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
||||
final PermissionBloc permissionBloc;
|
||||
final FileBrowserBloc fileBrowserBloc;
|
||||
final UploadBloc uploadBloc;
|
||||
final OrgApi orgApi;
|
||||
|
||||
OrganizationBloc(this.permissionBloc, this.fileBrowserBloc, this.uploadBloc)
|
||||
: super(OrganizationInitial()) {
|
||||
OrganizationBloc(
|
||||
this.permissionBloc,
|
||||
this.fileBrowserBloc,
|
||||
this.uploadBloc,
|
||||
this.orgApi,
|
||||
) : super(OrganizationInitial()) {
|
||||
on<LoadOrganizations>(_onLoadOrganizations);
|
||||
on<SelectOrganization>(_onSelectOrganization);
|
||||
on<CreateOrganization>(_onCreateOrganization);
|
||||
@@ -27,14 +33,13 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
||||
) async {
|
||||
emit(OrganizationLoading());
|
||||
try {
|
||||
// Simulate loading orgs from API
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final orgs = [
|
||||
const Organization(id: 'org1', name: 'Personal', role: 'admin'),
|
||||
const Organization(id: 'org2', name: 'Company Inc', role: 'edit'),
|
||||
const Organization(id: 'org3', name: 'Side Project', role: 'admin'),
|
||||
];
|
||||
emit(OrganizationLoaded(organizations: orgs, selectedOrg: orgs.first));
|
||||
final orgs = await orgApi.getOrganizations();
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: orgs,
|
||||
selectedOrg: orgs.isNotEmpty ? orgs.first : null,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(OrganizationError(e.toString()));
|
||||
}
|
||||
@@ -103,13 +108,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
||||
),
|
||||
);
|
||||
try {
|
||||
// Simulate API call
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final newOrg = Organization(
|
||||
id: 'org${currentState.organizations.length + 1}',
|
||||
name: name,
|
||||
role: 'admin',
|
||||
);
|
||||
final newOrg = await orgApi.createOrganization(name);
|
||||
final updatedOrgs = [...currentState.organizations, newOrg];
|
||||
emit(
|
||||
OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg),
|
||||
|
||||
@@ -3,20 +3,70 @@ import 'package:equatable/equatable.dart';
|
||||
class Organization extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final String role; // view, edit, admin
|
||||
final String? shortCode;
|
||||
final int? color;
|
||||
final String slug;
|
||||
final DateTime createdAt;
|
||||
|
||||
const Organization({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.role,
|
||||
this.shortCode,
|
||||
this.color,
|
||||
required this.slug,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
@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 {
|
||||
|
||||
@@ -15,8 +15,8 @@ class SessionBloc extends Bloc<SessionEvent, SessionState> {
|
||||
|
||||
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
|
||||
final expiresAt = DateTime.now().add(
|
||||
const Duration(hours: 1),
|
||||
); // Fake expiry
|
||||
const Duration(minutes: 15),
|
||||
); // Match Go
|
||||
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
||||
_startExpiryTimer(expiresAt);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class SessionBloc extends Bloc<SessionEvent, SessionState> {
|
||||
}
|
||||
|
||||
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));
|
||||
_startExpiryTimer(expiresAt);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@ import 'blocs/organization/organization_bloc.dart';
|
||||
import 'blocs/permission/permission_bloc.dart';
|
||||
import 'blocs/file_browser/file_browser_bloc.dart';
|
||||
import 'blocs/upload/upload_bloc.dart';
|
||||
import 'blocs/activity/activity_bloc.dart';
|
||||
import 'repositories/mock_file_repository.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/login_form.dart';
|
||||
import 'pages/file_explorer.dart';
|
||||
@@ -58,10 +62,13 @@ class MainApp extends StatelessWidget {
|
||||
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
|
||||
BlocProvider<PermissionBloc>(create: (_) => PermissionBloc()),
|
||||
BlocProvider<FileBrowserBloc>(
|
||||
create: (_) => FileBrowserBloc(FileService(MockFileRepository())),
|
||||
create: (context) => FileBrowserBloc(
|
||||
FileService(ApiClient(context.read<SessionBloc>())),
|
||||
),
|
||||
),
|
||||
BlocProvider<UploadBloc>(
|
||||
create: (_) => UploadBloc(MockFileRepository()),
|
||||
create: (_) =>
|
||||
UploadBloc(MockFileRepository()), // keep mock for upload
|
||||
),
|
||||
BlocProvider<OrganizationBloc>(
|
||||
lazy: true,
|
||||
@@ -69,8 +76,13 @@ class MainApp extends StatelessWidget {
|
||||
context.read<PermissionBloc>(),
|
||||
context.read<FileBrowserBloc>(),
|
||||
context.read<UploadBloc>(),
|
||||
OrgApi(ApiClient(context.read<SessionBloc>())),
|
||||
),
|
||||
),
|
||||
BlocProvider<ActivityBloc>(
|
||||
create: (context) =>
|
||||
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
|
||||
),
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
routerConfig: _router,
|
||||
|
||||
@@ -110,8 +110,8 @@ class _LoginFormState extends State<LoginForm> {
|
||||
onPressed: () {
|
||||
context.read<AuthBloc>().add(
|
||||
LoginRequested(
|
||||
_emailController.text,
|
||||
_passwordController.text,
|
||||
// _emailController.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) {
|
||||
final status = e.response?.statusCode;
|
||||
final data = e.response?.data;
|
||||
|
||||
@@ -3,38 +3,39 @@ import '../models/viewer_session.dart';
|
||||
import '../models/editor_session.dart';
|
||||
import '../models/annotation.dart';
|
||||
import '../repositories/file_repository.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class FileService {
|
||||
final FileRepository _fileRepository;
|
||||
final ApiClient _apiClient;
|
||||
|
||||
FileService(this._fileRepository);
|
||||
FileService(this._apiClient);
|
||||
|
||||
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
||||
if (path.isEmpty) {
|
||||
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 {
|
||||
if (path.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return await _fileRepository.getFile(orgId, path);
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
||||
if (file.name.isEmpty) {
|
||||
throw Exception('File name cannot be empty');
|
||||
}
|
||||
await _fileRepository.uploadFile(orgId, file);
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String orgId, String path) async {
|
||||
if (path.isEmpty) {
|
||||
throw Exception('Path cannot be empty');
|
||||
}
|
||||
await _fileRepository.deleteFile(orgId, path);
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
Future<void> createFolder(
|
||||
@@ -42,10 +43,7 @@ class FileService {
|
||||
String parentPath,
|
||||
String folderName,
|
||||
) async {
|
||||
if (folderName.isEmpty) {
|
||||
throw Exception('Folder name cannot be empty');
|
||||
}
|
||||
await _fileRepository.createFolder(orgId, parentPath, folderName);
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
Future<void> moveFile(
|
||||
@@ -53,24 +51,15 @@ class FileService {
|
||||
String sourcePath,
|
||||
String targetPath,
|
||||
) async {
|
||||
if (sourcePath.isEmpty || targetPath.isEmpty) {
|
||||
throw Exception('Paths cannot be empty');
|
||||
}
|
||||
await _fileRepository.moveFile(orgId, sourcePath, targetPath);
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
Future<void> renameFile(String orgId, String path, String newName) async {
|
||||
if (path.isEmpty || newName.isEmpty) {
|
||||
throw Exception('Path and new name cannot be empty');
|
||||
}
|
||||
await _fileRepository.renameFile(orgId, path, newName);
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
Future<List<FileItem>> searchFiles(String orgId, String query) async {
|
||||
if (query.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
return await _fileRepository.searchFiles(orgId, query);
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
Future<ViewerSession> requestViewerSession(
|
||||
@@ -80,7 +69,10 @@ class FileService {
|
||||
if (orgId.isEmpty || fileId.isEmpty) {
|
||||
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(
|
||||
@@ -90,7 +82,10 @@ class FileService {
|
||||
if (orgId.isEmpty || fileId.isEmpty) {
|
||||
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(
|
||||
@@ -101,6 +96,13 @@ class FileService {
|
||||
if (orgId.isEmpty || fileId.isEmpty) {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
var user User
|
||||
err := db.QueryRowContext(ctx, `
|
||||
@@ -122,3 +132,89 @@ func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go.b0esche.cloud/backend/internal/audit"
|
||||
"go.b0esche.cloud/backend/internal/auth"
|
||||
"go.b0esche.cloud/backend/internal/config"
|
||||
"go.b0esche.cloud/backend/internal/database"
|
||||
"go.b0esche.cloud/backend/internal/middleware"
|
||||
"go.b0esche.cloud/backend/internal/org"
|
||||
"go.b0esche.cloud/backend/internal/permission"
|
||||
"go.b0esche.cloud/backend/pkg/jwt"
|
||||
|
||||
"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 {
|
||||
@@ -31,13 +36,57 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
||||
authLoginHandler(w, req, authService)
|
||||
})
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -59,7 +108,7 @@ func authLoginHandler(w http.ResponseWriter, r *http.Request, authService *auth.
|
||||
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")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
@@ -76,7 +125,18 @@ func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Con
|
||||
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 {
|
||||
http.Error(w, "Token generation failed", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -91,3 +151,277 @@ func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Con
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -39,4 +39,14 @@ CREATE TABLE audit_logs (
|
||||
success BOOLEAN NOT NULL,
|
||||
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
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