#82: DataTransferService.kt

plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/DataTransferService.kt

Path: plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/DataTransferService.kt · Lines: 115 · Language: Kotlin

Purpose: Core service of the DataTransfer plugin — a file-sharing system embedded in ProjectForge. Creates personal file boxes per user, puts files into them via JCR (Jackrabbit content repository), and generates shareable links. Implements DataTransferInterface — the contract that other ProjectForge components use to programmatically drop files into users' boxes (e.g., report generators, invoice exporters).

Source: GitHub

115 lines · 80 code · 22 comments · 13 blank
CommitMessage
9ebb885222016-07-18 Initial commit

The plugin bridge — self-registration at startup

@Service
class DataTransferService : DataTransferInterface {
    @PostConstruct
    private fun postConstruct() {
        dataTransferBridge.register(this)   // "I'm a DataTransfer provider!"
    }
}

This is the plugin self-registration pattern. When Spring creates this bean, @PostConstruct fires and the service registers itself with DataTransferBridge. The bridge maintains a collection of all DataTransferInterface implementations. Other parts of ProjectForge (report generators, script executors, invoice exporters) query the bridge to find available data transfer providers — they don't need to know about this specific plugin. This is the Observer pattern applied to plugin discovery: plugins register themselves; consumers discover them through the bridge.

Why not just @Autowired? Because there might be MULTIPLE data transfer providers (different plugins). The bridge pattern allows consumers to iterate over all providers or select one by criteria, whereas @Autowired with a list would couple the consumer to Spring's injection mechanism.

Personal box — each user gets private file storage

override fun getPersonalBoxOfUserLink(userId: Long): String {
    val personalBox = dataTransferAreaDao.ensurePersonalBox(userId)
        ?: throw IllegalStateException("Personal box not found for user with ID $userId.")
    return getDataTransferAreaLink(personalBox.id)
}

ensurePersonalBox() — the "ensure" prefix is a ProjectForge convention: create if missing, return existing if found. This is idempotent — calling it twice for the same user returns the same box. The DAO likely checks the database for an existing personal box and creates one if none exists.

The Elvis operator ?:: Kotlin's null-safety operator. If ensurePersonalBox returns null (unexpected — it should always succeed), the IllegalStateException fires. The exception message includes the user ID for debugging — a log grepper can find which user caused the failure.

The returned link points to DataTransferPageRest — a REST endpoint. The link is used in email notifications: "Your invoice has been generated. Download it here: https://projectforge.acme.com/rs/datatransfer/..."

The core operation — putting a file into a user's box (lines 82-114)

override fun putFileInUsersInBox(
    receiver: PFUserDO,           // Who gets the file
    filename: String,             // Display name
    content: ByteArray,           // Raw file bytes
    description: String?          // Optional description
): Boolean {                       // Returns true on success, false on failure (no throw!)
    
    val personalBox = dataTransferAreaDao.ensurePersonalBox(receiver.id)
    
    attachmentsService.addAttachment(
        dataTransferAreaPagesRest.jcrPath!!,  // JCR node path for storage
        fileInfo = FileInfo(filename, content.size.toLong(), description),
        content = content,
        baseDao = dataTransferAreaDao,        // DAO for access control
        obj = personalBox,                    // The entity being attached to
        accessChecker = dataTransferAreaPagesRest.attachmentsAccessChecker
    )
    return true
  } catch (ex: Exception) {
    log.error("Can't put document '${filename}' of size ...", ex)
    return false                              // Graceful failure — caller handles it
  }
}

attachmentsService.addAttachment() — the bridge to JCR (Java Content Repository). Files aren't stored as database BLOBs. They go into Apache Jackrabbit — a hierarchical content repository that handles versioning, access control, and binary storage. The JCR path comes from dataTransferAreaPagesRest.jcrPath — each data transfer area has its own node in the JCR tree.

The 6 parameters to addAttachment() reveal the attachment system's contract:

ParameterPurpose
jcrPathJCR node path — where in the content tree to store
fileInfoMetadata: filename, size, description. Wrapped in a FileInfo data class.
contentRaw bytes — the actual file. ByteArray means the entire file is in memory. Large files (>100MB) would cause OOM.
baseDaoThe DAO responsible for the entity — used for history tracking and access control
objThe owning entity — the personal box that "owns" this file
accessCheckerDetermines who can read/write/delete this attachment

Error handling — return false, never throw

Return type Boolean instead of Unit: This is a deliberate design choice. The method returns true on success, false on failure. It NEVER throws (the catch block catches Exception — the broadest possible). The caller can decide how to handle failure: show an error message, retry, or silently continue.

Logging with formatBytes(): content.size.formatBytes() is a Kotlin extension function (imported from org.projectforge.common.extensions). It converts raw byte counts to human-readable format ("1.5 MB", "340 KB"). Used in the log message: "Can't put document 'invoice.pdf' of size 1.5 MB into user 'Kai Reinhard' personal box" — this is actionable for support debugging.

The huge !! on line 94: dataTransferAreaPagesRest.jcrPath!! — this will throw NullPointerException if jcrPath is null. The !! operator means "I'm certain this is never null." If it IS null, the NPE is caught by the catch (Exception) block and logged. But ideally, this should have a proper null check with a descriptive error message.

Kotlin features used

FeatureLineWhat it does
lateinit var46-58Spring-injected dependencies declared as lateinit — initialized after construction by Spring, guaranteed non-null after @PostConstruct
by lazy60val jcrPath is computed on first access, then cached. Delegates to dataTransferAreaPagesRest.jcrPath. The !! on the delegate means the lazy val itself can throw NPE.
private val log41KotlinLogging.logger {} from the mu library — creates a logger with the class name automatically. No need to pass DataTransferService::class.java.
Elvis ?:69,91Null-coalescing — if left side is null, throw the exception on the right
String templates69,91,105,109"user with ID $userId" — Kotlin string interpolation. Variables and expressions inside ${} are evaluated inline.

How this connects to the rest of the system

This service is the bridge between three subsystems:

  1. Plugin consumers — script executors, report generators, invoice exporters. They call dataTransferBridge.getProviders() → find DataTransferInterface → call putFileInUsersInBox(). They don't know about JCR, personal boxes, or access control.
  2. JCR storageattachmentsService.addAttachment() stores files in Apache Jackrabbit. The JCR handles versioning, access control, and binary storage. Files aren't in the database.
  3. REST APIDataTransferPageRest and DataTransferAreaPagesRest expose download/upload endpoints. The getDataTransferAreaLink() method generates URLs for these endpoints.

The design pattern is a facade: complex JCR operations are hidden behind a simple putFileInUsersInBox() interface.