backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.
The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
478 lines
16 KiB
Go
478 lines
16 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"veza-backend-api/internal/models"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// DataExportService gère l'export de données utilisateur pour la conformité GDPR (BE-SVC-022)
|
|
type DataExportService struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewDataExportService crée un nouveau service d'export de données
|
|
func NewDataExportService(db *gorm.DB, logger *zap.Logger) *DataExportService {
|
|
return &DataExportService{
|
|
db: db,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// MessageExport représente un message chat exporté (v0.10.8 F065)
|
|
type MessageExport struct {
|
|
ID uuid.UUID `json:"id"`
|
|
RoomID uuid.UUID `json:"room_id"`
|
|
Content string `json:"content"`
|
|
MessageType string `json:"message_type"`
|
|
ReplyToID *uuid.UUID `json:"reply_to_id,omitempty"`
|
|
IsEdited bool `json:"is_edited"`
|
|
IsPinned bool `json:"is_pinned"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// PlaybackHistoryExport représente un historique d'écoute exporté (v0.10.8 F065)
|
|
type PlaybackHistoryExport struct {
|
|
ID uuid.UUID `json:"id"`
|
|
TrackID uuid.UUID `json:"track_id"`
|
|
PlayedDuration int `json:"played_duration"`
|
|
CompletionPercentage int `json:"completion_percentage"`
|
|
Source string `json:"source,omitempty"`
|
|
DeviceType string `json:"device_type,omitempty"`
|
|
PlayedAt time.Time `json:"played_at"`
|
|
}
|
|
|
|
// UserDataExport représente toutes les données d'un utilisateur à exporter
|
|
type UserDataExport struct {
|
|
UserID uuid.UUID `json:"user_id"`
|
|
ExportedAt time.Time `json:"exported_at"`
|
|
Profile *UserProfileExport `json:"profile"`
|
|
Settings *UserSettingsExport `json:"settings"`
|
|
Tracks []TrackExport `json:"tracks"`
|
|
Playlists []PlaylistExport `json:"playlists"`
|
|
Comments []CommentExport `json:"comments"`
|
|
Likes []LikeExport `json:"likes"`
|
|
Messages []MessageExport `json:"messages,omitempty"`
|
|
PlaybackHistory []PlaybackHistoryExport `json:"playback_history,omitempty"`
|
|
Analytics []AnalyticsExport `json:"analytics"`
|
|
FederatedIDs []FederatedIDExport `json:"federated_identities"`
|
|
Roles []RoleExport `json:"roles"`
|
|
CloudFiles []CloudFileExport `json:"cloud_files,omitempty"`
|
|
Gear []GearExport `json:"gear,omitempty"`
|
|
}
|
|
|
|
// CloudFileExport represents cloud file metadata for export
|
|
type CloudFileExport struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Filename string `json:"filename"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
MimeType string `json:"mime_type"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// GearExport represents gear item metadata for export
|
|
type GearExport struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
Category string `json:"category"`
|
|
Brand string `json:"brand"`
|
|
Model string `json:"model"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// UserProfileExport représente les données de profil utilisateur
|
|
type UserProfileExport struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Avatar string `json:"avatar"`
|
|
Bio string `json:"bio"`
|
|
Location string `json:"location"`
|
|
Birthdate *time.Time `json:"birthdate"`
|
|
Gender string `json:"gender"`
|
|
Role string `json:"role"`
|
|
IsActive bool `json:"is_active"`
|
|
IsVerified bool `json:"is_verified"`
|
|
IsPublic bool `json:"is_public"`
|
|
LastLoginAt *time.Time `json:"last_login_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// UserSettingsExport représente les paramètres utilisateur
|
|
type UserSettingsExport struct {
|
|
EmailNotifications bool `json:"email_notifications"`
|
|
PushNotifications bool `json:"push_notifications"`
|
|
BrowserNotifications bool `json:"browser_notifications"`
|
|
EmailOnFollow bool `json:"email_on_follow"`
|
|
EmailOnLike bool `json:"email_on_like"`
|
|
EmailOnComment bool `json:"email_on_comment"`
|
|
EmailOnMessage bool `json:"email_on_message"`
|
|
EmailOnMention bool `json:"email_on_mention"`
|
|
EmailMarketing bool `json:"email_marketing"`
|
|
AllowSearchIndexing bool `json:"allow_search_indexing"`
|
|
ShowActivity bool `json:"show_activity"`
|
|
ExplicitContent bool `json:"explicit_content"`
|
|
Autoplay bool `json:"autoplay"`
|
|
}
|
|
|
|
// TrackExport représente un track exporté
|
|
type TrackExport struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Title string `json:"title"`
|
|
Artist string `json:"artist"`
|
|
Album string `json:"album"`
|
|
Duration int `json:"duration"`
|
|
Genre string `json:"genre"`
|
|
Year int `json:"year"`
|
|
FilePath string `json:"file_path"`
|
|
CoverArtPath string `json:"cover_art_path"`
|
|
IsPublic bool `json:"is_public"`
|
|
Status string `json:"status"`
|
|
PlayCount int64 `json:"play_count"`
|
|
LikeCount int64 `json:"like_count"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// PlaylistExport représente une playlist exportée
|
|
type PlaylistExport struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
IsPublic bool `json:"is_public"`
|
|
TrackCount int `json:"track_count"`
|
|
FollowerCount int `json:"follower_count"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// CommentExport représente un commentaire exporté
|
|
type CommentExport struct {
|
|
ID uuid.UUID `json:"id"`
|
|
TrackID uuid.UUID `json:"track_id"`
|
|
Content string `json:"content"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// LikeExport représente un like exporté
|
|
type LikeExport struct {
|
|
ID uuid.UUID `json:"id"`
|
|
TrackID uuid.UUID `json:"track_id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// AnalyticsExport représente des analytics exportées
|
|
type AnalyticsExport struct {
|
|
ID uuid.UUID `json:"id"`
|
|
TrackID uuid.UUID `json:"track_id"`
|
|
PlayTime int `json:"play_time"`
|
|
PauseCount int `json:"pause_count"`
|
|
SeekCount int `json:"seek_count"`
|
|
CompletionRate float64 `json:"completion_rate"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
EndedAt *time.Time `json:"ended_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// FederatedIDExport représente une identité fédérée exportée
|
|
type FederatedIDExport struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Provider string `json:"provider"`
|
|
ProviderID string `json:"provider_id"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"display_name"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// RoleExport représente un rôle exporté
|
|
type RoleExport struct {
|
|
ID uuid.UUID `json:"id"`
|
|
RoleName string `json:"role_name"`
|
|
AssignedAt time.Time `json:"assigned_at"`
|
|
ExpiresAt *time.Time `json:"expires_at"`
|
|
IsActive bool `json:"is_active"`
|
|
}
|
|
|
|
// ExportUserData exporte toutes les données d'un utilisateur (BE-SVC-022)
|
|
func (s *DataExportService) ExportUserData(ctx context.Context, userID uuid.UUID) (*UserDataExport, error) {
|
|
export := &UserDataExport{
|
|
UserID: userID,
|
|
ExportedAt: time.Now(),
|
|
}
|
|
|
|
// 1. Récupérer le profil utilisateur
|
|
var user models.User
|
|
if err := s.db.WithContext(ctx).First(&user, "id = ?", userID).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
|
}
|
|
|
|
export.Profile = &UserProfileExport{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
FirstName: user.FirstName,
|
|
LastName: user.LastName,
|
|
Avatar: user.Avatar,
|
|
Bio: user.Bio,
|
|
Location: user.Location,
|
|
Birthdate: user.Birthdate,
|
|
Gender: user.Gender,
|
|
Role: user.Role,
|
|
IsActive: user.IsActive,
|
|
IsVerified: user.IsVerified,
|
|
IsPublic: user.IsPublic,
|
|
LastLoginAt: user.LastLoginAt,
|
|
CreatedAt: user.CreatedAt,
|
|
UpdatedAt: user.UpdatedAt,
|
|
}
|
|
|
|
// 2. Récupérer les paramètres utilisateur
|
|
var settings models.UserSettings
|
|
if err := s.db.WithContext(ctx).First(&settings, "user_id = ?", userID).Error; err == nil {
|
|
export.Settings = &UserSettingsExport{
|
|
EmailNotifications: settings.EmailNotifications,
|
|
PushNotifications: settings.PushNotifications,
|
|
BrowserNotifications: settings.BrowserNotifications,
|
|
EmailOnFollow: settings.EmailOnFollow,
|
|
EmailOnLike: settings.EmailOnLike,
|
|
EmailOnComment: settings.EmailOnComment,
|
|
EmailOnMessage: settings.EmailOnMessage,
|
|
EmailOnMention: settings.EmailOnMention,
|
|
EmailMarketing: settings.EmailMarketing,
|
|
AllowSearchIndexing: settings.AllowSearchIndexing,
|
|
ShowActivity: settings.ShowActivity,
|
|
ExplicitContent: settings.ExplicitContent,
|
|
Autoplay: settings.Autoplay,
|
|
}
|
|
}
|
|
|
|
// 3. Récupérer les tracks de l'utilisateur
|
|
var tracks []models.Track
|
|
if err := s.db.WithContext(ctx).Where("creator_id = ?", userID).Find(&tracks).Error; err == nil {
|
|
export.Tracks = make([]TrackExport, len(tracks))
|
|
for i, track := range tracks {
|
|
export.Tracks[i] = TrackExport{
|
|
ID: track.ID,
|
|
Title: track.Title,
|
|
Artist: track.Artist,
|
|
Album: track.Album,
|
|
Duration: track.Duration,
|
|
Genre: track.Genre,
|
|
Year: track.Year,
|
|
FilePath: track.FilePath,
|
|
CoverArtPath: track.CoverArtPath,
|
|
IsPublic: track.IsPublic,
|
|
Status: string(track.Status),
|
|
PlayCount: track.PlayCount,
|
|
LikeCount: track.LikeCount,
|
|
CreatedAt: track.CreatedAt,
|
|
UpdatedAt: track.UpdatedAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Récupérer les playlists de l'utilisateur
|
|
var playlists []models.Playlist
|
|
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&playlists).Error; err == nil {
|
|
export.Playlists = make([]PlaylistExport, len(playlists))
|
|
for i, playlist := range playlists {
|
|
export.Playlists[i] = PlaylistExport{
|
|
ID: playlist.ID,
|
|
Title: playlist.Title,
|
|
Description: playlist.Description,
|
|
IsPublic: playlist.IsPublic,
|
|
TrackCount: playlist.TrackCount,
|
|
FollowerCount: playlist.FollowerCount,
|
|
CreatedAt: playlist.CreatedAt,
|
|
UpdatedAt: playlist.UpdatedAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Récupérer les commentaires de l'utilisateur
|
|
var comments []models.TrackComment
|
|
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&comments).Error; err == nil {
|
|
export.Comments = make([]CommentExport, len(comments))
|
|
for i, comment := range comments {
|
|
export.Comments[i] = CommentExport{
|
|
ID: comment.ID,
|
|
TrackID: comment.TrackID,
|
|
Content: comment.Content,
|
|
CreatedAt: comment.CreatedAt,
|
|
UpdatedAt: comment.UpdatedAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 6. Récupérer les likes de l'utilisateur
|
|
var likes []models.TrackLike
|
|
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&likes).Error; err == nil {
|
|
export.Likes = make([]LikeExport, len(likes))
|
|
for i, like := range likes {
|
|
export.Likes[i] = LikeExport{
|
|
ID: like.ID,
|
|
TrackID: like.TrackID,
|
|
CreatedAt: like.CreatedAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 7. Récupérer les analytics de l'utilisateur
|
|
var analytics []models.PlaybackAnalytics
|
|
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&analytics).Error; err == nil {
|
|
export.Analytics = make([]AnalyticsExport, len(analytics))
|
|
for i, a := range analytics {
|
|
export.Analytics[i] = AnalyticsExport{
|
|
ID: a.ID,
|
|
TrackID: a.TrackID,
|
|
PlayTime: a.PlayTime,
|
|
PauseCount: a.PauseCount,
|
|
SeekCount: a.SeekCount,
|
|
CompletionRate: a.CompletionRate,
|
|
StartedAt: a.StartedAt,
|
|
EndedAt: a.EndedAt,
|
|
CreatedAt: a.CreatedAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 8. Récupérer les identités fédérées
|
|
var federatedIDs []models.FederatedIdentity
|
|
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&federatedIDs).Error; err == nil {
|
|
export.FederatedIDs = make([]FederatedIDExport, len(federatedIDs))
|
|
for i, fid := range federatedIDs {
|
|
export.FederatedIDs[i] = FederatedIDExport{
|
|
ID: fid.ID,
|
|
Provider: fid.Provider,
|
|
ProviderID: fid.ProviderID,
|
|
Email: fid.Email,
|
|
DisplayName: fid.DisplayName,
|
|
CreatedAt: fid.CreatedAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 9. Récupérer les rôles de l'utilisateur
|
|
var userRoles []models.UserRole
|
|
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&userRoles).Error; err == nil {
|
|
export.Roles = make([]RoleExport, len(userRoles))
|
|
for i, ur := range userRoles {
|
|
export.Roles[i] = RoleExport{
|
|
ID: ur.ID,
|
|
RoleName: ur.RoleName,
|
|
AssignedAt: ur.AssignedAt,
|
|
ExpiresAt: ur.ExpiresAt,
|
|
IsActive: ur.IsActive,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 10. Récupérer les messages chat (v0.10.8 F065)
|
|
var chatMessages []models.ChatMessage
|
|
if err := s.db.WithContext(ctx).Where("sender_id = ?", userID).Find(&chatMessages).Error; err == nil {
|
|
export.Messages = make([]MessageExport, len(chatMessages))
|
|
for i, m := range chatMessages {
|
|
export.Messages[i] = MessageExport{
|
|
ID: m.ID,
|
|
RoomID: m.ConversationID,
|
|
Content: m.Content,
|
|
MessageType: m.MessageType,
|
|
ReplyToID: m.ReplyToID,
|
|
IsEdited: m.IsEdited,
|
|
IsPinned: m.IsPinned,
|
|
CreatedAt: m.CreatedAt,
|
|
UpdatedAt: m.UpdatedAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 11. Récupérer l'historique d'écoute (v0.10.8 F065) - playback_history ou playback_analytics
|
|
var playbackRows []struct {
|
|
ID uuid.UUID
|
|
TrackID uuid.UUID
|
|
PlayedDuration int
|
|
CompletionPercentage int
|
|
Source *string
|
|
DeviceType *string
|
|
PlayedAt time.Time
|
|
}
|
|
if err := s.db.WithContext(ctx).Raw(`
|
|
SELECT id, track_id, played_duration, completion_percentage, source, device_type, played_at
|
|
FROM playback_history WHERE user_id = ?
|
|
`, userID).Scan(&playbackRows).Error; err == nil {
|
|
export.PlaybackHistory = make([]PlaybackHistoryExport, len(playbackRows))
|
|
for i, r := range playbackRows {
|
|
src, dev := "", ""
|
|
if r.Source != nil {
|
|
src = *r.Source
|
|
}
|
|
if r.DeviceType != nil {
|
|
dev = *r.DeviceType
|
|
}
|
|
export.PlaybackHistory[i] = PlaybackHistoryExport{
|
|
ID: r.ID,
|
|
TrackID: r.TrackID,
|
|
PlayedDuration: r.PlayedDuration,
|
|
CompletionPercentage: r.CompletionPercentage,
|
|
Source: src,
|
|
DeviceType: dev,
|
|
PlayedAt: r.PlayedAt,
|
|
}
|
|
}
|
|
}
|
|
// Fallback: playback_analytics si playback_history vide ou inexistant
|
|
if len(export.PlaybackHistory) == 0 {
|
|
var analytics []models.PlaybackAnalytics
|
|
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&analytics).Error; err == nil {
|
|
export.PlaybackHistory = make([]PlaybackHistoryExport, len(analytics))
|
|
for i, a := range analytics {
|
|
export.PlaybackHistory[i] = PlaybackHistoryExport{
|
|
ID: a.ID,
|
|
TrackID: a.TrackID,
|
|
PlayedDuration: a.PlayTime,
|
|
CompletionPercentage: int(a.CompletionRate),
|
|
PlayedAt: a.StartedAt,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
s.logger.Info("User data exported",
|
|
zap.String("user_id", userID.String()),
|
|
zap.Int("tracks", len(export.Tracks)),
|
|
zap.Int("playlists", len(export.Playlists)),
|
|
zap.Int("comments", len(export.Comments)),
|
|
zap.Int("likes", len(export.Likes)),
|
|
zap.Int("analytics", len(export.Analytics)),
|
|
)
|
|
|
|
return export, nil
|
|
}
|
|
|
|
// ExportUserDataAsJSON exporte les données utilisateur au format JSON
|
|
func (s *DataExportService) ExportUserDataAsJSON(ctx context.Context, userID uuid.UUID) ([]byte, error) {
|
|
export, err := s.ExportUserData(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
jsonData, err := json.MarshalIndent(export, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal export data: %w", err)
|
|
}
|
|
|
|
return jsonData, nil
|
|
}
|