From b35adc3d0662cc12131e4dd58d4e1069ae3a7871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Thu, 18 Dec 2025 00:02:50 +0100 Subject: [PATCH] full stack first commit --- .../lib/blocs/activity/activity_bloc.dart | 25 ++ .../lib/blocs/activity/activity_event.dart | 17 + .../lib/blocs/activity/activity_state.dart | 31 ++ b0esche_cloud/lib/blocs/auth/auth_bloc.dart | 12 +- b0esche_cloud/lib/blocs/auth/auth_event.dart | 10 +- .../blocs/organization/organization_bloc.dart | 33 +- .../organization/organization_state.dart | 64 +++- .../lib/blocs/session/session_bloc.dart | 6 +- b0esche_cloud/lib/main.dart | 16 +- b0esche_cloud/lib/pages/login_form.dart | 4 +- b0esche_cloud/lib/services/activity_api.dart | 15 + b0esche_cloud/lib/services/api_client.dart | 13 + b0esche_cloud/lib/services/file_service.dart | 70 ++-- b0esche_cloud/lib/services/org_api.dart | 23 ++ go_cloud/internal/database/db.go | 96 +++++ go_cloud/internal/http/routes.go | 340 +++++++++++++++++- go_cloud/internal/org/org.go | 17 + go_cloud/migrations/0001_initial.sql | 10 + 18 files changed, 717 insertions(+), 85 deletions(-) create mode 100644 b0esche_cloud/lib/blocs/activity/activity_bloc.dart create mode 100644 b0esche_cloud/lib/blocs/activity/activity_event.dart create mode 100644 b0esche_cloud/lib/blocs/activity/activity_state.dart create mode 100644 b0esche_cloud/lib/services/activity_api.dart create mode 100644 b0esche_cloud/lib/services/org_api.dart diff --git a/b0esche_cloud/lib/blocs/activity/activity_bloc.dart b/b0esche_cloud/lib/blocs/activity/activity_bloc.dart new file mode 100644 index 0000000..0d24282 --- /dev/null +++ b/b0esche_cloud/lib/blocs/activity/activity_bloc.dart @@ -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 { + final ActivityApi activityApi; + + ActivityBloc(this.activityApi) : super(ActivityInitial()) { + on(_onLoadOrgActivities); + } + + void _onLoadOrgActivities( + LoadOrgActivities event, + Emitter emit, + ) async { + emit(ActivityLoading()); + try { + final activities = await activityApi.getOrgActivities(event.orgId); + emit(ActivityLoaded(activities: activities)); + } catch (e) { + emit(ActivityError(e.toString())); + } + } +} diff --git a/b0esche_cloud/lib/blocs/activity/activity_event.dart b/b0esche_cloud/lib/blocs/activity/activity_event.dart new file mode 100644 index 0000000..d66a50a --- /dev/null +++ b/b0esche_cloud/lib/blocs/activity/activity_event.dart @@ -0,0 +1,17 @@ +import 'package:equatable/equatable.dart'; + +abstract class ActivityEvent extends Equatable { + const ActivityEvent(); + + @override + List get props => []; +} + +class LoadOrgActivities extends ActivityEvent { + final String orgId; + + const LoadOrgActivities(this.orgId); + + @override + List get props => [orgId]; +} diff --git a/b0esche_cloud/lib/blocs/activity/activity_state.dart b/b0esche_cloud/lib/blocs/activity/activity_state.dart new file mode 100644 index 0000000..8a531e0 --- /dev/null +++ b/b0esche_cloud/lib/blocs/activity/activity_state.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import '../organization/organization_state.dart'; + +abstract class ActivityState extends Equatable { + const ActivityState(); + + @override + List get props => []; +} + +class ActivityInitial extends ActivityState {} + +class ActivityLoading extends ActivityState {} + +class ActivityLoaded extends ActivityState { + final List activities; + + const ActivityLoaded({required this.activities}); + + @override + List get props => [activities]; +} + +class ActivityError extends ActivityState { + final String error; + + const ActivityError(this.error); + + @override + List get props => [error]; +} diff --git a/b0esche_cloud/lib/blocs/auth/auth_bloc.dart b/b0esche_cloud/lib/blocs/auth/auth_bloc.dart index ff4d431..4ff9950 100644 --- a/b0esche_cloud/lib/blocs/auth/auth_bloc.dart +++ b/b0esche_cloud/lib/blocs/auth/auth_bloc.dart @@ -11,14 +11,10 @@ class AuthBloc extends Bloc { void _onLoginRequested(LoginRequested event, Emitter 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( diff --git a/b0esche_cloud/lib/blocs/auth/auth_event.dart b/b0esche_cloud/lib/blocs/auth/auth_event.dart index 4df3a53..6d9f8bd 100644 --- a/b0esche_cloud/lib/blocs/auth/auth_event.dart +++ b/b0esche_cloud/lib/blocs/auth/auth_event.dart @@ -7,15 +7,7 @@ abstract class AuthEvent extends Equatable { List get props => []; } -class LoginRequested extends AuthEvent { - final String email; - final String password; - - const LoginRequested(this.email, this.password); - - @override - List get props => [email, password]; -} +class LoginRequested extends AuthEvent {} class LogoutRequested extends AuthEvent {} diff --git a/b0esche_cloud/lib/blocs/organization/organization_bloc.dart b/b0esche_cloud/lib/blocs/organization/organization_bloc.dart index 65e1471..b801bbf 100644 --- a/b0esche_cloud/lib/blocs/organization/organization_bloc.dart +++ b/b0esche_cloud/lib/blocs/organization/organization_bloc.dart @@ -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 { 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(_onLoadOrganizations); on(_onSelectOrganization); on(_onCreateOrganization); @@ -27,14 +33,13 @@ class OrganizationBloc extends Bloc { ) 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 { ), ); 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), diff --git a/b0esche_cloud/lib/blocs/organization/organization_state.dart b/b0esche_cloud/lib/blocs/organization/organization_state.dart index 65b2479..ae32b6e 100644 --- a/b0esche_cloud/lib/blocs/organization/organization_state.dart +++ b/b0esche_cloud/lib/blocs/organization/organization_state.dart @@ -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 get props => [id, name, role, shortCode, color]; + List get props => [id, name, slug, createdAt]; + + factory Organization.fromJson(Map 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 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 get props => [ + id, + userId, + orgId, + fileId, + action, + metadata, + timestamp, + ]; + + factory Activity.fromJson(Map 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 { diff --git a/b0esche_cloud/lib/blocs/session/session_bloc.dart b/b0esche_cloud/lib/blocs/session/session_bloc.dart index 13cccf4..c1a0d66 100644 --- a/b0esche_cloud/lib/blocs/session/session_bloc.dart +++ b/b0esche_cloud/lib/blocs/session/session_bloc.dart @@ -15,8 +15,8 @@ class SessionBloc extends Bloc { void _onSessionStarted(SessionStarted event, Emitter 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 { } void _onSessionRefreshed(SessionRefreshed event, Emitter 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); } diff --git a/b0esche_cloud/lib/main.dart b/b0esche_cloud/lib/main.dart index 3dd422e..bc32cd7 100644 --- a/b0esche_cloud/lib/main.dart +++ b/b0esche_cloud/lib/main.dart @@ -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(create: (_) => SessionBloc()), BlocProvider(create: (_) => PermissionBloc()), BlocProvider( - create: (_) => FileBrowserBloc(FileService(MockFileRepository())), + create: (context) => FileBrowserBloc( + FileService(ApiClient(context.read())), + ), ), BlocProvider( - create: (_) => UploadBloc(MockFileRepository()), + create: (_) => + UploadBloc(MockFileRepository()), // keep mock for upload ), BlocProvider( lazy: true, @@ -69,8 +76,13 @@ class MainApp extends StatelessWidget { context.read(), context.read(), context.read(), + OrgApi(ApiClient(context.read())), ), ), + BlocProvider( + create: (context) => + ActivityBloc(ActivityApi(ApiClient(context.read()))), + ), ], child: MaterialApp.router( routerConfig: _router, diff --git a/b0esche_cloud/lib/pages/login_form.dart b/b0esche_cloud/lib/pages/login_form.dart index ae5f6a4..af0025e 100644 --- a/b0esche_cloud/lib/pages/login_form.dart +++ b/b0esche_cloud/lib/pages/login_form.dart @@ -110,8 +110,8 @@ class _LoginFormState extends State { onPressed: () { context.read().add( LoginRequested( - _emailController.text, - _passwordController.text, + // _emailController.text, + // _passwordController.text, ), ); }, diff --git a/b0esche_cloud/lib/services/activity_api.dart b/b0esche_cloud/lib/services/activity_api.dart new file mode 100644 index 0000000..45fdd2e --- /dev/null +++ b/b0esche_cloud/lib/services/activity_api.dart @@ -0,0 +1,15 @@ +import '../blocs/organization/organization_state.dart'; +import 'api_client.dart'; + +class ActivityApi { + final ApiClient _apiClient; + + ActivityApi(this._apiClient); + + Future> getOrgActivities(String orgId) async { + return await _apiClient.getList( + '/orgs/$orgId/activity', + fromJson: (data) => Activity.fromJson(data), + ); + } +} diff --git a/b0esche_cloud/lib/services/api_client.dart b/b0esche_cloud/lib/services/api_client.dart index ee77eb4..01d7c3c 100644 --- a/b0esche_cloud/lib/services/api_client.dart +++ b/b0esche_cloud/lib/services/api_client.dart @@ -102,6 +102,19 @@ class ApiClient { } } + Future> getList( + String path, { + Map? 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; diff --git a/b0esche_cloud/lib/services/file_service.dart b/b0esche_cloud/lib/services/file_service.dart index 15d226a..4ddb87f 100644 --- a/b0esche_cloud/lib/services/file_service.dart +++ b/b0esche_cloud/lib/services/file_service.dart @@ -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> 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 getFile(String orgId, String path) async { - if (path.isEmpty) { - return null; - } - return await _fileRepository.getFile(orgId, path); + throw UnimplementedError(); } Future 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 deleteFile(String orgId, String path) async { - if (path.isEmpty) { - throw Exception('Path cannot be empty'); - } - await _fileRepository.deleteFile(orgId, path); + throw UnimplementedError(); } Future 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 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 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> searchFiles(String orgId, String query) async { - if (query.isEmpty) { - return []; - } - return await _fileRepository.searchFiles(orgId, query); + throw UnimplementedError(); } Future 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 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 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, + ); } } diff --git a/b0esche_cloud/lib/services/org_api.dart b/b0esche_cloud/lib/services/org_api.dart new file mode 100644 index 0000000..d034a51 --- /dev/null +++ b/b0esche_cloud/lib/services/org_api.dart @@ -0,0 +1,23 @@ +import '../blocs/organization/organization_state.dart'; +import 'api_client.dart'; + +class OrgApi { + final ApiClient _apiClient; + + OrgApi(this._apiClient); + + Future> getOrganizations() async { + return await _apiClient.getList( + '/orgs', + fromJson: (data) => Organization.fromJson(data), + ); + } + + Future createOrganization(String name) async { + return await _apiClient.post( + '/orgs', + data: {'name': name}, + fromJson: (data) => Organization.fromJson(data), + ); + } +} diff --git a/go_cloud/internal/database/db.go b/go_cloud/internal/database/db.go index a8fe0e0..2f235b5 100644 --- a/go_cloud/internal/database/db.go +++ b/go_cloud/internal/database/db.go @@ -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 +} diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 2936748..02bfe2d 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -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) +} diff --git a/go_cloud/internal/org/org.go b/go_cloud/internal/org/org.go index c958bd7..1c40892 100644 --- a/go_cloud/internal/org/org.go +++ b/go_cloud/internal/org/org.go @@ -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 +} diff --git a/go_cloud/migrations/0001_initial.sql b/go_cloud/migrations/0001_initial.sql index 6ae09a2..52f0159 100644 --- a/go_cloud/migrations/0001_initial.sql +++ b/go_cloud/migrations/0001_initial.sql @@ -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() ); \ No newline at end of file