Skip to content
This is my space, where experience meets the will to start over. This is my space, where experience meets the will to start over.

The first step is knowing where you want to go.

  • Home
  • Coding Hub
    • Software & Project
      • Small Biz Ops – S.B.O.
        • SmallBizOps – Day 10/90
      • CRM/ERP
      • MyTracker
      • My Budget
    • Form Zero to “WoW”
      • JavaScript from Zero (Completed)
        • 2. Remove and Edit List Items
        • 3. Separate HTML and JavaScript, Use addEventListener and Conditional Logic
        • 4. Add Dynamic CSS Classes
        • 5. Save & Restore Your List with localStorage
        • 6 – Turn Your App into a Full To-Do List
      • Python from Zero (Completed)
        • 2. Lists & Loops
        • 3. Conditional Menus
        • 4. Edit & Remove Tasks (with closing: Python vs PHP and Large Data)
        • 5 – Save to File: Make Your Tasks Survive Restarts
        • 6 — Pythin from zero – Final Project Polishing: Numbering, Formatting, and Preparing for CSV
      • Rust – From Zero to “WoW” (Completed)
        • 1 – Setup and Project Structure in Rust
        • 2 – User input: validation and error handling
        • 3 – Rust from Zero to “WoW – BMI Calculation and Conditional Logic
        • 4 –Rust – Clear, Formatted Output
        • 5 – Rust – Final Thoughts: Precision as a Form of Respect
      • Go from Zero to “WoW” (Completed)
        • 1 – Why Go Is Perfect for a Personal Expense Tracker
        • 2 – Logging Expenses and Console Input
        • 3 – Go from Zero to “WoW” – Smart Filtering & Display Logic
        • 4 – Go – Saving Data to Local Files
        • 5 – Go – Final Project – Expense Tracker in Go
      • C++ from Zero to “WoW” (Completed)
        • 1 – Why C++ for file organization?
        • 2 – C++ – File Type Detection and Classification
        • 3 – C++ – Creating & Managing Subfolders
        • 4 – C++ – Safe File Movement and User Feedback
        • 5 – C++ – Order as Mental Clarity
      • Ubuntu – From Zero to “WoW” (Completed)
        • 2 – Ubuntu – The Desktop Environment and Essential Commands
        • 3 – Ubuntu – Managing Files, Folders, and Permissions
        • 4 – Ubuntu – Installing and Updating Software with APT and Snap
        • 5 – Ubuntu – Customizing the Desktop Environment
        • 6 – Ubuntu – Network and Device Configuration
        • 7 – Ubuntu – User Management & System Security — “The Cathedral of Permissions”
        • 8 – Ubuntu – The Talking Machine: Terminal & Bash Scripting
        • 9 – Ubuntu – Ubuntu as a Server or Development Environment
        • 10 – Ubuntu – Backup, Maintenance & Troubleshooting
    • Git Hub Repository
      • Small Biz Ops – S.B.O.
      • Mini ERP – PHP & MySQL
      • CleverCRM (Java, Spring Boot)
      • FraudWatch (Python, FastAPI + scikit-learn)
      • OnboardIQ – Smart Onboarding Portal (Flask + SQLite Demo)
    • ArchPilot
      • 1-Users & Roles, End-to-End (Architecture, Database, and Cross-Framework Code)
      • 2 – Client Registry (CRM) Across Frameworks
      • 3 – Project & Budget Tracker (ERP)
      • 4 – Approval Workflow Engine Multi-step routing, status tracking, escalation paths
      • 5 – Audit Trail & Versioning
    • Small Biz Ops – S.B.O.
  • Vivere in USA
  • P4Y
  • Testi poetici
    • 1 – Sospeso
    • 2 – Il bicchiere di vetro quieto
    • 3 – Quando l’amore inciampa
    • 4 – Ma chi siete davvero?
    • 5 – Above the Thread of Day
    • 6 – The Truth That Doesn’t Exist
    • 7 – All of You, I Miss
    • 8 – The Captain and the Ocean
    • 9 – Between Light and Mist
    • 10 – Il peso delle scelte
  • Contact
  • Admin
This is my space, where experience meets the will to start over.
This is my space, where experience meets the will to start over.

The first step is knowing where you want to go.

Coding – ArchPilot – Step 2 – Client Registry (CRM) Across Frameworks

Posted on 20 Novembre 202520 Novembre 2025 By Francesco

(Organizations, Contacts, Interactions – Schema + Code for Laravel, Django, Spring Boot, ASP.NET Core, Node.js, Drupal)

Introduction

ArchPilot has a very specific goal: to show how the same business problem can be solved across different technology ecosystems, while keeping a consistent data model and clear business rules.

In Step 1 – User & Role Management, we built the security backbone:

  • Secure login (email/password, lockout, optional MFA)
  • Roles and permissions (e.g., Manager with ViewBudget)
  • Protected endpoints and pages
  • Audit logs and onboarding states

In this Step 2 – Client Registry (CRM), we build the relational core of the business:

  • Organizations – the companies/customers you work with.
  • Contacts – people linked to those organizations.
  • Interactions – history of calls, meetings, emails, and notes tied to organizations and contacts.

We keep the same ArchPilot philosophy:

  • Use one canonical schema (organizations, contacts, organization_contact, interactions).
  • Implement it in six different stacks: Laravel, Django, Spring Boot, ASP.NET Core, Node.js + Express, Drupal.
  • Wire everything into the RBAC from Step 1 with permissions like ViewClient, EditClient, ViewInteraction, EditInteraction.
  • Reuse audit_logs from Step 1 to track who did what, when, and on which client or contact.

Step 2 is intended as a reusable CRM building block. Same schema, same behavior, multiple ecosystems. From there, you can compare:

  • How each framework models entities (migrations, models, entities, schemas).
  • How routes/pages are protected (middleware, decorators, policies, permissions).
  • How a realistic flow looks: client list, client detail page, interaction log.

Later steps (Project & Budget Tracker, Approval Workflow, Audit & Versioning, Alerts, Reporting) will build on top of this same Client Registry across all frameworks.


What Step 2 Does (Simple Summary)

Business capabilities:

  • Organizations
    • Create / edit / archive organizations (e.g., “ACME Corp”).
    • Key attributes: legal name, display name, website, sector, size band, tax ID, owner (account manager).
  • Contacts
    • Create / edit people (e.g., “Jane Smith – CFO”).
    • Email (often unique), phone/mobile, job title, and functional role (e.g., billing contact, decision maker).
  • Org–Contact Relationships
    • One contact may be associated with multiple organizations (consultant, advisor).
    • Role per organization (e.g., CFO for ACME, Board Member for Beta Ltd).
  • Interactions
    • Log phone calls, meetings, emails, internal notes.
    • Can be attached to an organization, a contact, or both.
    • Channel, subject, body, outcome, timestamp.
  • Permissions
    • ViewClient, EditClient for organizations/contacts.
    • ViewInteraction, EditInteraction for interaction history.
    • No permission → no client data or interactions.
  • Audit
    • Every critical change (create_org, update_contact, create_interaction) is written to audit_logs (Step 1).

Canonical Data Model (Shared by All Frameworks)

New CRM entities:

  • organizations — companies/customers.
  • contacts — people.
  • organization_contact — M:N relation between organizations and contacts, plus a role.
  • interactions — logged history of calls, meetings, emails, and notes.

Logical relationships:

users (1..*)──(0..*) organizations        -- owner/responsible
users (1..*)──(0..*) contacts             -- owner/responsible

organizations (1..*)──(0..*) organization_contact (0..*)──(1..*) contacts

organizations (1..*)──(0..*) interactions
contacts      (1..*)──(0..*) interactions
users         (1..*)──(0..*) interactions -- who performed/logged the interaction

Permissions in permissions.slug (from Step 1):

  • ViewClient, EditClient
  • ViewInteraction, EditInteraction

Roles in roles.slug:

  • Example: Manager with all 4 permissions above.

Canonical SQL DDL (PostgreSQL-Friendly)

(For MySQL/SQL Server: use CHAR(36) or uniqueidentifier instead of UUID, and adjust defaults accordingly.)

-- ORGANIZATIONS
CREATE TABLE organizations (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  legal_name     VARCHAR(200) NOT NULL,
  display_name   VARCHAR(160) NOT NULL,
  website        VARCHAR(200),
  sector         VARCHAR(80),    -- e.g., Manufacturing, SaaS, Retail
  size_band      VARCHAR(40),    -- e.g., 1-10, 11-50, 51-200
  tax_id         VARCHAR(50),    -- VAT / EIN / company tax code
  owner_user_id  UUID,           -- account manager
  is_active      BOOLEAN NOT NULL DEFAULT TRUE,
  created_at     TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
  updated_at     TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
  FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);

CREATE INDEX idx_organizations_display_name ON organizations(display_name);
CREATE INDEX idx_organizations_sector ON organizations(sector);


-- CONTACTS
CREATE TABLE contacts (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  first_name     VARCHAR(80) NOT NULL,
  last_name      VARCHAR(80) NOT NULL,
  email          VARCHAR(190),
  phone          VARCHAR(40),
  mobile         VARCHAR(40),
  job_title      VARCHAR(120),
  owner_user_id  UUID,
  is_active      BOOLEAN NOT NULL DEFAULT TRUE,
  created_at     TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
  updated_at     TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
  FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);

CREATE UNIQUE INDEX idx_contacts_email_unique
ON contacts(email)
WHERE email IS NOT NULL;


-- ORGANIZATION_CONTACT (M:N)
CREATE TABLE organization_contact (
  organization_id UUID NOT NULL,
  contact_id      UUID NOT NULL,
  role_label      VARCHAR(120),      -- e.g. 'CFO', 'Billing Contact'
  relationship_status VARCHAR(40),   -- e.g. 'Active','Prospect','Former'
  PRIMARY KEY (organization_id, contact_id),
  FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
  FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);


-- INTERACTIONS
CREATE TABLE interactions (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID,
  contact_id      UUID,
  user_id         UUID,      -- who logged/performed the interaction
  channel         VARCHAR(40) NOT NULL,  -- call,email,meeting,note
  subject         VARCHAR(200) NOT NULL,
  body            TEXT,
  outcome         VARCHAR(80),  -- e.g., 'Follow-up scheduled', 'Closed lost'
  happened_at     TIMESTAMP WITH TIME ZONE NOT NULL,
  created_at      TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
  FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL,
  FOREIGN KEY (contact_id)      REFERENCES contacts(id)      ON DELETE SET NULL,
  FOREIGN KEY (user_id)         REFERENCES users(id)         ON DELETE SET NULL
);

CREATE INDEX idx_interactions_org ON interactions(organization_id);
CREATE INDEX idx_interactions_contact ON interactions(contact_id);
CREATE INDEX idx_interactions_happened_at ON interactions(happened_at);

Why This Schema?

  • Separate organizations and contacts
    • Many contacts per organization and the same person can be linked to multiple organizations.
  • M:N link table (organization_contact)
    • Avoids duplicating contacts to represent multiple customer relationships.
  • Interactions “soft-linked”
    • You can log items attached only to an organization, only to a contact, or both.
  • Owner per entity
    • owner_user_id supports “account owner” logic and per-owner filters.
  • Consistent permissions
    • Uses the same permissions and roles design from Step 1 (e.g., ViewClient) to control visibility.

Indicative Business Rules

  • Contact email uniqueness
    • One email per system, or per tenant later (if you go multi-tenant).
  • Archival vs deletion
    • Use is_active for logical archive.
    • For stronger privacy (e.g., GDPR), you may add soft delete or anonymization.
  • Visibility rules
    • Default: users with ViewClient see all clients.
    • Enterprise: filter by owner_user_id or team/region for more granular access.
  • Audit logging
    • Write create_org, update_org, create_contact, create_interaction into audit_logs with meaningful metadata.

Framework-by-Framework Implementation

Below we hook the canonical schema into each stack and implement:

  • Schema/migrations/models
  • Basic listing and detail views
  • Interaction logging
  • Permission checks (RBAC from Step 1)

Laravel (PHP)

Migrations

// database/migrations/2025_11_06_100000_create_crm_tables.php
return new class extends Migration {
    public function up(): void
    {
        Schema::create('organizations', function (Blueprint $t) {
            $t->uuid('id')->primary();
            $t->string('legal_name', 200);
            $t->string('display_name', 160);
            $t->string('website', 200)->nullable();
            $t->string('sector', 80)->nullable();
            $t->string('size_band', 40)->nullable();
            $t->string('tax_id', 50)->nullable();
            $t->uuid('owner_user_id')->nullable();
            $t->boolean('is_active')->default(true);
            $t->timestampsTz();

            $t->foreign('owner_user_id')
              ->references('id')->on('users')
              ->onDelete('set null');

            $t->index('display_name');
            $t->index('sector');
        });

        Schema::create('contacts', function (Blueprint $t) {
            $t->uuid('id')->primary();
            $t->string('first_name', 80);
            $t->string('last_name', 80);
            $t->string('email', 190)->nullable()->unique();
            $t->string('phone', 40)->nullable();
            $t->string('mobile', 40)->nullable();
            $t->string('job_title', 120)->nullable();
            $t->uuid('owner_user_id')->nullable();
            $t->boolean('is_active')->default(true);
            $t->timestampsTz();

            $t->foreign('owner_user_id')
              ->references('id')->on('users')
              ->onDelete('set null');
        });

        Schema::create('organization_contact', function (Blueprint $t) {
            $t->uuid('organization_id');
            $t->uuid('contact_id');
            $t->string('role_label', 120)->nullable();
            $t->string('relationship_status', 40)->nullable();
            $t->primary(['organization_id', 'contact_id']);

            $t->foreign('organization_id')
              ->references('id')->on('organizations')
              ->onDelete('cascade');
            $t->foreign('contact_id')
              ->references('id')->on('contacts')
              ->onDelete('cascade');
        });

        Schema::create('interactions', function (Blueprint $t) {
            $t->uuid('id')->primary();
            $t->uuid('organization_id')->nullable();
            $t->uuid('contact_id')->nullable();
            $t->uuid('user_id')->nullable();
            $t->string('channel', 40);
            $t->string('subject', 200);
            $t->text('body')->nullable();
            $t->string('outcome', 80)->nullable();
            $t->timestampTz('happened_at');
            $t->timestampTz('created_at')->useCurrent();

            $t->foreign('organization_id')->references('id')->on('organizations')->onDelete('set null');
            $t->foreign('contact_id')->references('id')->on('contacts')->onDelete('set null');
            $t->foreign('user_id')->references('id')->on('users')->onDelete('set null');

            $t->index('organization_id');
            $t->index('contact_id');
            $t->index('happened_at');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('interactions');
        Schema::dropIfExists('organization_contact');
        Schema::dropIfExists('contacts');
        Schema::dropIfExists('organizations');
    }
};

Models

// app/Models/Organization.php
class Organization extends Model
{
    use HasUuids;

    protected $fillable = [
        'legal_name','display_name','website','sector',
        'size_band','tax_id','owner_user_id','is_active',
    ];

    public function owner()
    {
        return $this->belongsTo(User::class, 'owner_user_id');
    }

    public function contacts()
    {
        return $this->belongsToMany(Contact::class)
                    ->withPivot(['role_label','relationship_status']);
    }

    public function interactions()
    {
        return $this->hasMany(Interaction::class);
    }
}

// app/Models/Contact.php
class Contact extends Model
{
    use HasUuids;

    protected $fillable = [
        'first_name','last_name','email','phone','mobile',
        'job_title','owner_user_id','is_active',
    ];

    public function owner()
    {
        return $this->belongsTo(User::class, 'owner_user_id');
    }

    public function organizations()
    {
        return $this->belongsToMany(Organization::class)
                    ->withPivot(['role_label','relationship_status']);
    }

    public function interactions()
    {
        return $this->hasMany(Interaction::class);
    }
}

// app/Models/Interaction.php
class Interaction extends Model
{
    use HasUuids;

    public $timestamps = false;

    protected $fillable = [
        'organization_id','contact_id','user_id',
        'channel','subject','body','outcome',
        'happened_at','created_at',
    ];

    protected $dates = ['happened_at','created_at'];

    public function organization(){ return $this->belongsTo(Organization::class); }
    public function contact(){ return $this->belongsTo(Contact::class); }
    public function user(){ return $this->belongsTo(User::class); }
}

Seed CRM Permissions

// database/seeders/CrmPermissionSeeder.php
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;

class CrmPermissionSeeder extends Seeder
{
    public function run()
    {
        $perms = [
            'ViewClient',
            'EditClient',
            'ViewInteraction',
            'EditInteraction',
        ];

        foreach ($perms as $name) {
            Permission::firstOrCreate(['name' => $name]);
        }

        $manager = Role::firstOrCreate(['name' => 'Manager']);
        $manager->givePermissionTo($perms);
    }
}

Controllers (Listing + Detail + Interaction Logging)

// routes/web.php
Route::middleware(['auth'])->group(function () {
    Route::get('/organizations', [OrganizationController::class, 'index']);
    Route::get('/organizations/{org}', [OrganizationController::class, 'show']);
    Route::post('/organizations/{org}/interactions', [InteractionController::class, 'store']);
});
// app/Http/Controllers/OrganizationController.php
class OrganizationController extends Controller
{
    public function index(Request $request)
    {
        if (! $request->user()->can('ViewClient')) {
            abort(403, 'Missing permission: ViewClient');
        }

        $orgs = Organization::query()
            ->where('is_active', true)
            ->orderBy('display_name')
            ->take(50)
            ->get();

        return view('organizations.index', compact('orgs'));
    }

    public function show(Request $request, Organization $org)
    {
        if (! $request->user()->can('ViewClient')) {
            abort(403);
        }

        $interactions = $org->interactions()
            ->latest('happened_at')
            ->take(20)
            ->get();

        return view('organizations.show', compact('org', 'interactions'));
    }
}
// app/Http/Controllers/InteractionController.php
class InteractionController extends Controller
{
    public function store(Request $request, Organization $org)
    {
        $user = $request->user();

        if (! $user->can('EditInteraction')) {
            abort(403);
        }

        $data = $request->validate([
            'channel'     => 'required|string|max:40',
            'subject'     => 'required|string|max:200',
            'body'        => 'nullable|string',
            'outcome'     => 'nullable|string|max:80',
            'happened_at' => 'required|date',
            'contact_id'  => 'nullable|uuid|exists:contacts,id',
        ]);

        $interaction = Interaction::create([
            'organization_id' => $org->id,
            'contact_id'      => $data['contact_id'] ?? null,
            'user_id'         => $user->id,
            'channel'         => $data['channel'],
            'subject'         => $data['subject'],
            'body'            => $data['body'] ?? null,
            'outcome'         => $data['outcome'] ?? null,
            'happened_at'     => $data['happened_at'],
            'created_at'      => now(),
        ]);

        AuditLog::create([
            'user_id'     => $user->id,
            'action'      => 'create_interaction',
            'target_type' => 'interaction',
            'target_id'   => $interaction->id,
            'metadata'    => [
                'organization_id' => $org->id,
                'contact_id'      => $interaction->contact_id,
                'channel'         => $interaction->channel,
            ],
            'ip'          => $request->ip(),
            'ua'          => $request->userAgent(),
        ]);

        return redirect()->back()->with('status', 'Interaction logged.');
    }
}

Laravel strengths for this step

  • Very fast CRUD + UI development (Blade, Livewire, Nova/Filament).
  • Great fit for internal CRM/admin panels.

Trade-offs

  • For complex, multi-tenant CRM with heavy reporting you must be disciplined with architecture and caching.

Django (Python)

Models

# crm/models.py
import uuid
from django.db import models
from django.conf import settings

class Organization(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    legal_name = models.CharField(max_length=200)
    display_name = models.CharField(max_length=160)
    website = models.URLField(max_length=200, blank=True)
    sector = models.CharField(max_length=80, blank=True)
    size_band = models.CharField(max_length=40, blank=True)
    tax_id = models.CharField(max_length=50, blank=True)
    owner_user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True, blank=True,
        on_delete=models.SET_NULL,
        related_name='owned_organizations'
    )
    is_active = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        indexes = [
            models.Index(fields=['display_name']),
            models.Index(fields=['sector']),
        ]

    def __str__(self):
        return self.display_name


class Contact(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    first_name = models.CharField(max_length=80)
    last_name = models.CharField(max_length=80)
    email = models.EmailField(max_length=190, unique=True, null=True, blank=True)
    phone = models.CharField(max_length=40, blank=True)
    mobile = models.CharField(max_length=40, blank=True)
    job_title = models.CharField(max_length=120, blank=True)
    owner_user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True, blank=True,
        on_delete=models.SET_NULL,
        related_name='owned_contacts'
    )
    is_active = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return f"{self.first_name} {self.last_name}"


class OrganizationContact(models.Model):
    organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
    contact = models.ForeignKey(Contact, on_delete=models.CASCADE)
    role_label = models.CharField(max_length=120, blank=True)
    relationship_status = models.CharField(max_length=40, blank=True)

    class Meta:
        unique_together = ('organization', 'contact')


class Interaction(models.Model):
    CHANNEL_CHOICES = [
        ('call', 'Call'),
        ('email', 'Email'),
        ('meeting', 'Meeting'),
        ('note', 'Note'),
    ]

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    organization = models.ForeignKey(Organization, null=True, blank=True, on_delete=models.SET_NULL)
    contact = models.ForeignKey(Contact, null=True, blank=True, on_delete=models.SET_NULL)
    user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL)
    channel = models.CharField(max_length=40, choices=CHANNEL_CHOICES)
    subject = models.CharField(max_length=200)
    body = models.TextField(blank=True)
    outcome = models.CharField(max_length=80, blank=True)
    happened_at = models.DateTimeField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=['happened_at']),
            models.Index(fields=['organization']),
            models.Index(fields=['contact']),
        ]

Permissions (CRM-specific)

Example snippet to create custom permissions:

from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from .models import Organization, Interaction

def create_crm_permissions():
    org_ct = ContentType.objects.get_for_model(Organization)
    int_ct = ContentType.objects.get_for_model(Interaction)

    Permission.objects.get_or_create(
        codename='view_client',
        name='Can view clients',
        content_type=org_ct,
    )
    Permission.objects.get_or_create(
        codename='edit_client',
        name='Can edit clients',
        content_type=org_ct,
    )
    Permission.objects.get_or_create(
        codename='view_interaction',
        name='Can view interactions',
        content_type=int_ct,
    )
    Permission.objects.get_or_create(
        codename='edit_interaction',
        name='Can edit interactions',
        content_type=int_ct,
    )

Views – List, Detail, Log Interaction

# crm/views.py
from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import render, get_object_or_404, redirect
from django.utils import timezone
from .models import Organization, Interaction, Contact

@login_required
@permission_required('crm.view_client', raise_exception=True)
def organization_list(request):
    orgs = Organization.objects.filter(is_active=True).order_by('display_name')[:50]
    return render(request, 'crm/organization_list.html', {'organizations': orgs})


@login_required
@permission_required('crm.view_client', raise_exception=True)
def organization_detail(request, pk):
    org = get_object_or_404(Organization, pk=pk)
    interactions = Interaction.objects.filter(organization=org).order_by('-happened_at')[:20]
    return render(request, 'crm/organization_detail.html', {
        'organization': org,
        'interactions': interactions,
    })


@login_required
@permission_required('crm.edit_interaction', raise_exception=True)
def log_interaction(request, pk):
    org = get_object_or_404(Organization, pk=pk)

    if request.method == 'POST':
        channel = request.POST['channel']
        subject = request.POST['subject']
        body = request.POST.get('body', '')
        outcome = request.POST.get('outcome', '')
        happened_at = request.POST.get('happened_at') or timezone.now()
        contact_id = request.POST.get('contact_id')

        contact = Contact.objects.filter(pk=contact_id).first() if contact_id else None

        interaction = Interaction.objects.create(
            organization=org,
            contact=contact,
            user=request.user,
            channel=channel,
            subject=subject,
            body=body,
            outcome=outcome,
            happened_at=happened_at,
        )

        # AuditLog.objects.create(...)  # Reuse Step 1 audit table/model

        return redirect('crm:organization_detail', pk=org.pk)

    return render(request, 'crm/log_interaction.html', {'organization': org})

Django strengths for this step

  • Admin + generic views = very fast internal CRM.
  • permission_required decorator simplifies access control.

Trade-offs

  • Object-level permissions (per-org or per-owner) require extra tools like django-guardian or custom logic.

Spring Boot (Java)

Entities (excerpt)

// Organization.java
@Entity
@Table(name = "organizations")
public class Organization {

    @Id
    @GeneratedValue
    private UUID id;

    @Column(name = "legal_name", nullable = false, length = 200)
    private String legalName;

    @Column(name = "display_name", nullable = false, length = 160)
    private String displayName;

    private String website;
    private String sector;
    private String sizeBand;
    private String taxId;

    @Column(name = "owner_user_id")
    private UUID ownerUserId;

    private boolean isActive = true;

    @Column(name = "created_at")
    private OffsetDateTime createdAt = OffsetDateTime.now();

    @Column(name = "updated_at")
    private OffsetDateTime updatedAt = OffsetDateTime.now();

    // getters/setters
}

// Interaction.java
@Entity
@Table(name = "interactions")
public class Interaction {

    @Id
    @GeneratedValue
    private UUID id;

    @Column(name = "organization_id")
    private UUID organizationId;

    @Column(name = "contact_id")
    private UUID contactId;

    @Column(name = "user_id")
    private UUID userId;

    private String channel;
    private String subject;

    @Column(columnDefinition = "TEXT")
    private String body;

    private String outcome;

    @Column(name = "happened_at")
    private OffsetDateTime happenedAt;

    @Column(name = "created_at")
    private OffsetDateTime createdAt = OffsetDateTime.now();

    // getters/setters
}

Repositories

public interface OrganizationRepository extends JpaRepository<Organization, UUID> {
    List<Organization> findTop50ByIsActiveTrueOrderByDisplayNameAsc();
}

public interface InteractionRepository extends JpaRepository<Interaction, UUID> {
    List<Interaction> findTop20ByOrganizationIdOrderByHappenedAtDesc(UUID orgId);
}

Controller with @PreAuthorize

@RestController
@RequestMapping("/api/crm")
@RequiredArgsConstructor
public class CrmController {

    private final OrganizationRepository organizationRepository;
    private final InteractionRepository interactionRepository;
    private final AuditService auditService;

    @PreAuthorize("hasAuthority('ViewClient')")
    @GetMapping("/organizations")
    public List<Organization> listOrganizations() {
        return organizationRepository.findTop50ByIsActiveTrueOrderByDisplayNameAsc();
    }

    @PreAuthorize("hasAuthority('ViewClient')")
    @GetMapping("/organizations/{id}")
    public Map<String, Object> showOrganization(@PathVariable UUID id) {
        Organization org = organizationRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));

        List<Interaction> interactions =
                interactionRepository.findTop20ByOrganizationIdOrderByHappenedAtDesc(id);

        Map<String, Object> result = new HashMap<>();
        result.put("organization", org);
        result.put("interactions", interactions);
        return result;
    }

    @PreAuthorize("hasAuthority('EditInteraction')")
    @PostMapping("/organizations/{id}/interactions")
    public Interaction createInteraction(
            @PathVariable UUID id,
            @RequestBody CreateInteractionRequest dto,
            Authentication auth) {

        UUID userId = ((MyUserPrincipal) auth.getPrincipal()).getId();

        Interaction interaction = new Interaction();
        interaction.setOrganizationId(id);
        interaction.setContactId(dto.getContactId());
        interaction.setUserId(userId);
        interaction.setChannel(dto.getChannel());
        interaction.setSubject(dto.getSubject());
        interaction.setBody(dto.getBody());
        interaction.setOutcome(dto.getOutcome());
        interaction.setHappenedAt(dto.getHappenedAt());

        Interaction saved = interactionRepository.save(interaction);

        auditService.log(
                "create_interaction",
                userId,
                "interaction",
                saved.getId(),
                Map.of("organizationId", id, "channel", dto.getChannel())
        );

        return saved;
    }
}

Spring Boot strengths for this step

  • Enterprise-grade IAM, SSO, and policy engines.
  • Very strong fit for large, regulated CRM/ERP deployments.

Trade-offs

  • More verbose and heavier initial setup compared to Laravel/Django.
  • Requires architectural discipline from day one.

ASP.NET Core (C#)

EF Core Entities (excerpt)

public class Organization
{
    public Guid Id { get; set; }
    public string LegalName { get; set; } = default!;
    public string DisplayName { get; set; } = default!;
    public string? Website { get; set; }
    public string? Sector { get; set; }
    public string? SizeBand { get; set; }
    public string? TaxId { get; set; }
    public Guid? OwnerUserId { get; set; }
    public bool IsActive { get; set; } = true;
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

public class Interaction
{
    public Guid Id { get; set; }
    public Guid? OrganizationId { get; set; }
    public Guid? ContactId { get; set; }
    public Guid? UserId { get; set; }
    public string Channel { get; set; } = default!;
    public string Subject { get; set; } = default!;
    public string? Body { get; set; }
    public string? Outcome { get; set; }
    public DateTimeOffset HappenedAt { get; set; }
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class AppDbContext : DbContext
{
    public DbSet<Organization> Organizations => Set<Organization>();
    public DbSet<Interaction> Interactions => Set<Interaction>();

    protected override void OnModelCreating(ModelBuilder b)
    {
        b.Entity<Organization>()
            .HasIndex(x => x.DisplayName);
        // Map other constraints/indexes as needed
    }
}

Controller with Policies

[ApiController]
[Route("api/[controller]")]
public class CrmController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly IAuditLogger _audit;

    public CrmController(AppDbContext db, IAuditLogger audit)
    {
        _db = db;
        _audit = audit;
    }

    [HttpGet("organizations")]
    [Authorize(Policy = "ViewClient")]
    public async Task<IActionResult> GetOrganizations()
    {
        var orgs = await _db.Organizations
            .Where(o => o.IsActive)
            .OrderBy(o => o.DisplayName)
            .Take(50)
            .ToListAsync();

        return Ok(orgs);
    }

    [HttpPost("organizations/{id:guid}/interactions")]
    [Authorize(Policy = "EditInteraction")]
    public async Task<IActionResult> CreateInteraction(Guid id, [FromBody] CreateInteractionDto dto)
    {
        var userId = User.FindFirst("sub")?.Value;

        var interaction = new Interaction
        {
            Id = Guid.NewGuid(),
            OrganizationId = id,
            ContactId = dto.ContactId,
            UserId = userId is null ? null : Guid.Parse(userId),
            Channel = dto.Channel,
            Subject = dto.Subject,
            Body = dto.Body,
            Outcome = dto.Outcome,
            HappenedAt = dto.HappenedAt
        };

        _db.Interactions.Add(interaction);
        await _db.SaveChangesAsync();

        await _audit.LogAsync("create_interaction", interaction.Id, new {
            OrganizationId = id,
            interaction.Channel
        });

        return Ok(interaction);
    }
}

ASP.NET Core strengths for this step

  • High performance and great tooling.
  • Tight integration with Microsoft identity (Entra ID), SQL Server, Azure.

Trade-offs

  • Requires .NET skills in the team.
  • Mapping roles/permissions into claims and policies must be carefully designed.

Node.js + Express (Prisma ORM)

Prisma Schema

model Organization {
  id            String   @id @default(uuid())
  legal_name    String
  display_name  String
  website       String?
  sector        String?
  size_band     String?
  tax_id        String?
  owner_user_id String?
  is_active     Boolean  @default(true)
  created_at    DateTime @default(now())
  updated_at    DateTime @updatedAt

  contacts      OrganizationContact[]
  interactions  Interaction[]
}

model Contact {
  id            String   @id @default(uuid())
  first_name    String
  last_name     String
  email         String?  @unique
  phone         String?
  mobile        String?
  job_title     String?
  owner_user_id String?
  is_active     Boolean  @default(true)
  created_at    DateTime @default(now())
  updated_at    DateTime @updatedAt

  organizations OrganizationContact[]
  interactions  Interaction[]
}

model OrganizationContact {
  organization    Organization @relation(fields: [organization_id], references: [id])
  organization_id String
  contact         Contact      @relation(fields: [contact_id], references: [id])
  contact_id      String

  role_label          String?
  relationship_status String?

  @@id([organization_id, contact_id])
}

model Interaction {
  id              String   @id @default(uuid())
  organization    Organization? @relation(fields: [organization_id], references: [id])
  organization_id String?
  contact         Contact?      @relation(fields: [contact_id], references: [id])
  contact_id      String?
  user_id         String?
  channel         String
  subject         String
  body            String?
  outcome         String?
  happened_at     DateTime
  created_at      DateTime @default(now())
}

Authorization Middleware

// middleware/authz.js
function requirePermission(perm) {
  return (req, res, next) => {
    const perms = req.user?.permissions || [];
    if (!perms.includes(perm)) {
      return res.status(403).json({ error: 'Missing permission: ' + perm });
    }
    next();
  };
}

module.exports = { requirePermission };

Routes

// routes/crm.js
const router = require('express').Router();
const { prisma } = require('../prismaClient');
const { requireLogin } = require('../middleware/authn');
const { requirePermission } = require('../middleware/authz');

router.get(
  '/organizations',
  requireLogin,
  requirePermission('ViewClient'),
  async (req, res, next) => {
    try {
      const orgs = await prisma.organization.findMany({
        where: { is_active: true },
        orderBy: { display_name: 'asc' },
        take: 50,
      });
      res.json(orgs);
    } catch (err) {
      next(err);
    }
  }
);

router.post(
  '/organizations/:id/interactions',
  requireLogin,
  requirePermission('EditInteraction'),
  async (req, res, next) => {
    try {
      const { channel, subject, body, outcome, happened_at, contact_id } = req.body;
      const userId = req.user.id;
      const orgId = req.params.id;

      const interaction = await prisma.interaction.create({
        data: {
          organization_id: orgId,
          contact_id: contact_id || null,
          user_id: userId,
          channel,
          subject,
          body,
          outcome,
          happened_at: new Date(happened_at),
        },
      });

      // Optional: audit logging
      // await logAudit('create_interaction', userId, 'interaction', interaction.id, ...);

      res.status(201).json(interaction);
    } catch (err) {
      next(err);
    }
  }
);

module.exports = router;

Node.js strengths for this step

  • Prisma keeps schema close to the canonical SQL model.
  • Great for API-first and microservice architectures, plus real-time features.

Trade-offs

  • You must enforce standards (logging, tests, error handling) yourself.
  • Without a structured framework (e.g., NestJS), large CRM codebases can get messy.

Drupal

For Drupal, the most natural approach is:

  • Use content types for “Organization” and “Contact” with custom fields.
  • Use another content type or custom entity for “Interaction”, with Entity Reference fields pointing to Organization/Contact.
  • Use Drupal permissions to gate pages and lists.

Permissions

# mymodule.permissions.yml
view client:
  title: 'View CRM clients'
  description: 'View organizations and contacts in CRM'
edit client:
  title: 'Edit CRM clients'
  description: 'Create and edit organizations and contacts'
view interaction:
  title: 'View interactions'
edit interaction:
  title: 'Log and edit interactions'

Routing

# mymodule.routing.yml
mymodule.crm_organization_list:
  path: '/crm/organizations'
  defaults:
    _controller: '\Drupal\mymodule\Controller\CrmController::orgList'
    _title: 'Organizations'
  requirements:
    _permission: 'view client'

mymodule.crm_organization_view:
  path: '/crm/organization/{organization}'
  defaults:
    _controller: '\Drupal\mymodule\Controller\CrmController::orgView'
    _title: 'Organization'
  requirements:
    _permission: 'view client'

Controller (excerpt)

namespace Drupal\mymodule\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\node\NodeInterface;

class CrmController extends ControllerBase {

  public function orgList() {
    $storage = $this->entityTypeManager()->getStorage('node');
    $nids = $storage->getQuery()
      ->condition('type', 'organization')
      ->condition('status', 1)
      ->range(0, 50)
      ->execute();
    $nodes = $storage->loadMultiple($nids);

    $items = [];
    foreach ($nodes as $node) {
      $items[] = $node->toLink()->toString();
    }

    return [
      '#theme' => 'item_list',
      '#items' => $items,
    ];
  }

  public function orgView(NodeInterface $organization) {
    // You can configure a view mode that embeds a View
    // listing the last 20 related interactions.
    return $this->entityTypeManager()
      ->getViewBuilder('node')
      ->view($organization, 'full');
  }
}

Drupal strengths for this step

  • Excellent when CRM is part of a content-heavy portal.
  • Views + workflows + roles allow UI-driven configuration of client lists and dashboards.

Trade-offs

  • Not designed as an ERP/CRM core engine; too much business logic in custom modules can become heavy.

Quick Comparison – Where Each Framework Fits

FrameworkCRM StrengthsUse it when…
LaravelFast CRUD/admin, Blade/Livewire, Filament/NovaInternal CRM, MVPs, full-stack PHP teams
DjangoAdmin, generic views, Python ecosystemPortals, public sector, data-heavy internal tools
SpringEnterprise IAM/SSO, policies, auditEnterprise CRM/ERP, regulated environments
ASP.NETPerformance, Azure/Entra integration, MS toolingMicrosoft shops, Azure-first, Office/Power BI synergy
NodeFlexible API-first, microservices, real-timeAPI layer, microservices, full-stack JS teams
DrupalContent + fine-grained editorial permissionsContent-heavy sites plus light CRM capability

Conclusion – Which Framework Fits the Client Registry Best?

With Step 2, you’ve seen the same basic CRM module (organizations, contacts, interactions, permissions, audit) implemented across six ecosystems.
It’s natural to ask: “So which framework is best for a Client Registry?”
The honest answer: it depends on your context.

As a rough guide:

  • Laravel is great when you have a PHP team, a LAMP-style environment or shared hosting, and you need to ship quickly with CRUD, dashboards, and custom back office UIs.
  • Django fits well if you’re in the Python world (data, analytics, scripting) and want an internal CRM/portal where the built-in admin saves a lot of effort.
  • Spring Boot is a strong choice in Java enterprise ecosystems, where you must integrate with corporate IAM, SSO (OIDC/SAML), message brokers, and compliance-heavy infrastructure.
  • ASP.NET Core makes a lot of sense in Microsoft-centric environments (Entra ID, SQL Server, Azure) where CRM functionality needs to integrate cleanly with .NET, Office 365, Power BI, and internal standards.
  • Node.js + Express is ideal when your main goal is a light, flexible API layer, microservices, or real-time features, and your team is full-stack JavaScript.
  • Drupal is the right fit when CRM capability is a layer on top of a content-rich site, where editors and non-technical users need to manage content, customers, and interactions through the UI.

However, these guidelines alone are not enough. Choosing a framework must always be contextualized to:

  • Work environment: OS, databases, existing logging/monitoring, CI/CD, container/orchestration stacks.
  • Protocols and integrations: SSO (OIDC/SAML), messaging, existing APIs, VPNs, reverse proxies, API gateways, and internal security rules.
  • Internal governance: approved languages/frameworks, required logging/audit standards, retention and encryption policies, data residency constraints.
  • Team skills: what your team can build, debug, and maintain over years—not just what looks attractive on paper.

ArchPilot does not try to declare a universal winner.
Instead, it provides a shared vocabulary and reference: one canonical schema, one business capability, six implementations. From there, you can make an informed decision based on your actual environment, constraints, and business goals.


Disclaimers

ArchPilot is a personal, non-commercial project created for educational, demonstrative, and professional portfolio purposes. It is not affiliated with, endorsed by, or derived from any proprietary ERP/CRM vendor.

Open-source frameworks.
All frameworks referenced (Laravel, Django, Spring Boot, ASP.NET Core, Node.js/Express, Drupal) are open-source or publicly licensed. Their names and logos are trademarks of their respective owners.

Illustrative code.
Code snippets, database schemas, and configurations shown in ArchPilot are illustrative examples designed to explain architectural decisions across ecosystems. They are not production-ready and are provided “as is” without any warranty. Before using anything in a real environment, you must:

  • perform full code reviews and security reviews,
  • run functional and load tests,
  • design and verify logging, monitoring, backup, and disaster-recovery plans.

Security & compliance.
If you implement similar concepts in a real system, you are solely responsible for compliance with applicable laws and regulations (e.g., GDPR, HIPAA, SOC 2, PCI-DSS, data residency rules, internal policies). This includes, among other things:

  • secure authentication and authorization,
  • proper secret management (passwords, API keys, certificates),
  • rate-limiting and protection against CSRF/XSS/SQL injection and other attacks,
  • encryption in transit and, when required, at rest,
  • data lifecycle management (retention, anonymization, right to be forgotten).

Data and scenarios.
Any reference to clients, data, workflows, or business scenarios is fictional or anonymized. No real, confidential, or third-party proprietary data is included.

SSO and identity.
Single sign-on integrations (e.g., OIDC/SAML) require correct configuration with your identity provider. Do not hard-code secrets in source code; use secure secret-management systems (vaults, key management services, etc.).

No legal or professional advice.
ArchPilot does not provide legal, tax, security, or compliance advice. For decisions with legal or regulatory impact, consult qualified professionals (lawyers, DPOs, security consultants, auditors).

Performance & costs.
Any statements about performance, scalability, or infrastructure costs are indicative only and will vary with real workloads, architecture, hardware, cloud/provider choices, and the optimizations you implement.

By using any part of ArchPilot’s materials, you agree that you assume all risks and responsibilities for verifying, adapting, and deploying them in your own environment.

Post Views: 304

Condividi:

  • Condividi su Facebook (Si apre in una nuova finestra) Facebook
  • Condividi su X (Si apre in una nuova finestra) X
Coding ArchPilot

Navigazione articoli

Previous post
Next post

Francesco

My name is Francesco Boschi, originally from Italy and currently based in the United States. For over twenty years, I’ve worked as a manager and consultant across diverse sectors — from education and cultural institutions to the food industry — developing skills in operational management, strategic consulting, and complex problem-solving. In recent years, I’ve combined this experience with a strong passion for software development, creating custom tools designed to simplify workflows and meet real business needs.

Relocating to the U.S. marks the beginning of a new chapter: a personal and professional decision driven by the desire to be close to my son and to embrace new challenges in a different environment. Today, my goal is to turn my experience into meaningful solutions, blending strategic vision with technical expertise to help people and organizations work more effectively.

I enjoy moving between different worlds, adapting tools and approaches to people and contexts. I bring leadership, flexibility, attention to detail, analytical thinking, and a strong problem-solving mindset — along with a deep curiosity to learn and grow. Above all, I believe in sharing: I’m always eager to offer my experience to support the growth of others.

Related Posts

Coding

ArchPilot – Step 3 – Project & Budget Tracker (ERP)

Posted on 30 Novembre 202530 Novembre 2025

A cross-framework showcase of the Project & Budget Tracker module, demonstrating how different technologies handle the same ERP workflow with clarity, adaptability, and architectural insight.

Condividi:

  • Condividi su Facebook (Si apre in una nuova finestra) Facebook
  • Condividi su X (Si apre in una nuova finestra) X
Read More
Python

Coding – Step 10 – Python from Zero – Lesson 1: Variables, Input, and Simple Logic

Posted on 28 Luglio 202523 Agosto 2025

Welcome to the first real Python lesson — where you’ll build a mini console app using real code, logic, and user input. No theory-only content here: we learn by writing and running code immediately. 📚 What You’ll Learn ✅ How to use print() and input()✅ How to store and use…

Condividi:

  • Condividi su Facebook (Si apre in una nuova finestra) Facebook
  • Condividi su X (Si apre in una nuova finestra) X
Read More
Coding

Coding – Step 14.10 – Ubuntu – Backup, Maintenance & Troubleshooting

Posted on 29 Ottobre 202529 Ottobre 2025

Learn how to protect your Ubuntu system like a professional: create smart backups, perform safe updates, and troubleshoot errors confidently using real tools.

Condividi:

  • Condividi su Facebook (Si apre in una nuova finestra) Facebook
  • Condividi su X (Si apre in una nuova finestra) X
Read More

Iscriviti alla nostra Newsletter

🤞 Let's keep in touch

We do not send spam! Read our Privacy policy for more information.

Controlla la tua casella di posta o la cartella spam per confermare la tua iscrizione

Cerca nel sito

©2026 This is my space, where experience meets the will to start over. | WordPress Theme by SuperbThemes