Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166])
	by code.netlandish.com (Postfix) with ESMTP id 9CCCD37
	for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 21 Feb 2025 23:04:23 +0000 (UTC)
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.219.181; helo=mail-yb1-f181.google.com; envelope-from=peter@netlandish.com; receiver=<UNKNOWN> 
Authentication-Results: mail.netlandish.com;
	dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=qELmnPGS
Received: from mail-yb1-f181.google.com (mail-yb1-f181.google.com [209.85.219.181])
	by mail.netlandish.com (Postfix) with ESMTP id 6784C1D6432
	for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 21 Feb 2025 23:12:24 +0000 (UTC)
Received: by mail-yb1-f181.google.com with SMTP id 3f1490d57ef6-e587cca1e47so2540400276.0
        for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 21 Feb 2025 15:12:24 -0800 (PST)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=netlandish.com; s=google; t=1740179543; x=1740784343; darn=lists.code.netlandish.com;
        h=content-transfer-encoding:mime-version:message-id:date:subject:to
         :from:from:to:cc:subject:date:message-id:reply-to;
        bh=7mqgXTU++h6+NjNlbRSwiadcYWe8Z2O9whXD+39VLUE=;
        b=qELmnPGSv3dNT0xibVP7/Wzj/E7gszjlrvqxbaX8q+RFxy3Q+3bAgE/a1IofBvOAHT
         w3HpP/bn1rIGDi8F6+6HIuzauXe08XxOAetofO6OOcCWemqo92SRsbt5Uz4LIUDvwFvR
         NV2555S4BTYnioT5MhHtSY0+2Utqe885zPAlg=
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=1e100.net; s=20230601; t=1740179543; x=1740784343;
        h=content-transfer-encoding:mime-version:message-id:date:subject:to
         :from:x-gm-message-state:from:to:cc:subject:date:message-id:reply-to;
        bh=7mqgXTU++h6+NjNlbRSwiadcYWe8Z2O9whXD+39VLUE=;
        b=R7q1jLZQSp5kCgtVGkxmCKHRghjnZ2139l0cPAL1V1ULWHN0zXeYWFScOYp4UMn2IG
         emnDuNbSFMgoPxCz3fwfM8LJNgvq/JpYwPaz2uKRMhQRff7fb+hLnXBc7USPaRuwR7ks
         r7ezYSOQrUGBlM80Oo/1EaEVgG7gF26wBTTdGownFlarOao493boH/d1xB2Hy4J6VE4o
         8M432vE0p8asV+p/162l/jBZMkQSPJOMrK0yq0geJJzkChYbWSX223C7pezY5JM1/kPM
         tQTrgiCgL3wHWBp4go1a24jCq4osZjXU9755548HOaxNmkB26iaS3812TES29FP+7ar9
         JaBA==
X-Gm-Message-State: AOJu0Yyq9s/Bzdnbpj7do1JDWIdS+z6bswYwtVFPvuCk1HhE0ZLeBDPf
	3up8EhwIITjueFCENoX2SyfWu2TT0dJyLQYyA79vhI2mEpxwspyKNkByxJ7vVhQ3BhxChq9BKMt
	oWAc=
X-Gm-Gg: ASbGncsySgTy19/QsNodq5uLsfbePn8RHu2LiWf5xdwbjjYRjWwxmd+ayc1A+bVMUp6
	oIy2eD+eBSX8p/zysAH2QC2krBsWrEi2/i82b7mG8bJnlCU1qiZFv80fzEyp1R7IvfBwclXz5Xf
	DvFahETNvCDIGkXQ1begu2IDBfO+hd7pEt0Egg/aH2KSuO6j4Khas4V4rGMZBKn2gBydYOWl9oh
	Y0I1VGVwYpQJJ/8AvF2lkL4MQjuYLIjGTLVoTy7o4A0Gtk+fBmGKg1vP+arhOX6UBcvkjy6Ys8y
	1/S8nL/t70/7LYV4JWv3Ij0USw==
X-Google-Smtp-Source: AGHT+IER9x9oQJaQfy89zB1g/FDEtWzEBM1TJxW2q5t1WOL1uN76XICUdkAFXVZvqAacFZIp2+EROw==
X-Received: by 2002:a05:6902:1546:b0:e58:835:a7b0 with SMTP id 3f1490d57ef6-e5e2467a886mr3712139276.37.1740179543169;
        Fri, 21 Feb 2025 15:12:23 -0800 (PST)
Received: from localhost ([2803:2d60:1118:5ee:bd8:a3af:9c0f:81c0])
        by smtp.gmail.com with ESMTPSA id 3f1490d57ef6-e5dae10b9c9sm4992661276.48.2025.02.21.15.12.22
        for <~netlandish/links-dev@lists.code.netlandish.com>
        (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
        Fri, 21 Feb 2025 15:12:22 -0800 (PST)
From: Peter Sanchez <peter@netlandish.com>
To: ~netlandish/links-dev@lists.code.netlandish.com
Subject: [PATCH links] Adding audit logs for various data modification functions.
Date: Fri, 21 Feb 2025 17:11:39 -0600
Message-ID: <20250221231218.16424-1-peter@netlandish.com>
X-Mailer: git-send-email 2.47.2
MIME-Version: 1.0
Content-Transfer-Encoding: 8bit

---
Still pending some handlers to display audit logs but I wanted to ensure
we get record keeping in place asap as more and more users register.

 accounts/userfetch.go         |  65 +++++
 api/graph/schema.graphqls     |   6 +-
 api/graph/schema.resolvers.go | 459 +++++++++++++++++++++++++++++++---
 models/audit_log.go           |  65 +++++
 models/models.go              |   5 -
 5 files changed, 554 insertions(+), 46 deletions(-)
 create mode 100644 models/audit_log.go

diff --git a/accounts/userfetch.go b/accounts/userfetch.go
index 6b8bba4..b509086 100644
--- a/accounts/userfetch.go
+++ b/accounts/userfetch.go
@@ -143,7 +143,20 @@ func (u *UserFetch) ProcessSuccessfulLogout(c echo.Context) error {
 
 // ProcessSuccessfulPasswordChange handle tasks after user is logged in
 func (u *UserFetch) ProcessSuccessfulPasswordChange(c echo.Context) error {
+	gctx := c.(*server.Context)
 	lt := localizer.GetSessionLocalizer(c)
+	err := models.RecordAuditLog(
+		c.Request().Context(),
+		int(gctx.User.GetID()),
+		c.RealIP(),
+		models.LOG_ACCT_PASSWORD_CHANGE,
+		"Successfully changed password.",
+		nil,
+	)
+	if err != nil {
+		return err
+	}
+
 	messages.Success(c, lt.Translate("You've successfully updated your password."))
 	return nil
 }
@@ -151,6 +164,19 @@ func (u *UserFetch) ProcessSuccessfulPasswordChange(c echo.Context) error {
 // ProcessSuccessfulPasswordReset handle tasks after user is logged in
 func (u *UserFetch) ProcessSuccessfulPasswordReset(c echo.Context) error {
 	lt := localizer.GetSessionLocalizer(c)
+	user := c.Get("user").(*models.User)
+	err := models.RecordAuditLog(
+		c.Request().Context(),
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_ACCT_PASSWORD_RESET,
+		"Successfully reset password.",
+		nil,
+	)
+	if err != nil {
+		return err
+	}
+
 	messages.Success(c, lt.Translate("You've successfully updated your password."))
 	return nil
 }
@@ -158,6 +184,19 @@ func (u *UserFetch) ProcessSuccessfulPasswordReset(c echo.Context) error {
 // ProcessSuccessfulEmailUpdate handle tasks after user is logged in
 func (u *UserFetch) ProcessSuccessfulEmailUpdate(c echo.Context) error {
 	lt := localizer.GetSessionLocalizer(c)
+	user := c.Get("user").(*models.User)
+	err := models.RecordAuditLog(
+		c.Request().Context(),
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_ACCT_EMAIL_UPDATE,
+		"Successfully updated account email.",
+		nil,
+	)
+	if err != nil {
+		return err
+	}
+
 	messages.Success(c, lt.Translate("You've successfully updated your email address."))
 	return nil
 }
@@ -180,6 +219,19 @@ func (u *UserFetch) ProcessSuccessfulEmailConfirmation(c echo.Context) error {
 	if err != nil {
 		gctx.Server.Logger().Printf("Error queueing SendySubscribeTask: %v", err)
 	}
+
+	err = models.RecordAuditLog(
+		c.Request().Context(),
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_ACCT_EMAIL_CONF,
+		"Successfully confirmed account email.",
+		nil,
+	)
+	if err != nil {
+		return err
+	}
+
 	messages.Success(c, lt.Translate("You've successfully confirmed your email address."))
 	return nil
 }
@@ -234,6 +286,19 @@ func (u *UserFetch) ProcessSuccessfulLogin(c echo.Context, user gobwebs.User) er
 	links.SetUserSession(c, lUser)
 
 	lt := localizer.GetSessionLocalizer(c)
+
+	err := models.RecordAuditLog(
+		c.Request().Context(),
+		int(lUser.ID),
+		c.RealIP(),
+		models.LOG_ACCT_LOGIN,
+		"Successful account login.",
+		nil,
+	)
+	if err != nil {
+		return err
+	}
+
 	messages.Success(c, lt.Translate("Successful login."))
 	return nil
 }
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index 4612125..79f72eb 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -814,6 +814,9 @@ type Mutation {
     "Create a new organization"
     addOrganization(input: OrganizationInput!): Organization! @access(scope: ORGS, kind: RW)
 
+    "Update organization details"
+    updateOrganization(input: UpdateOrganizationInput): Organization! @access(scope: ORGS, kind: RW)
+
     "Add/Edit/Delete organization link. Also used edit/delete notes"
     addLink(input: LinkInput): OrgLink! @access(scope: LINKS, kind: RW)
     updateLink(input: UpdateLinkInput): OrgLink! @access(scope: LINKS, kind: RW)
@@ -834,9 +837,6 @@ type Mutation {
     "Update user profile"
     updateProfile(input: ProfileInput): User! @access(scope: PROFILE, kind: RW)
 
-    "Update organization details"
-    updateOrganization(input: UpdateOrganizationInput): Organization! @access(scope: ORGS, kind: RW)
-
     "Manage custom domains for an organization"
     addDomain(input: DomainInput!): Domain! @access(scope: DOMAINS, kind: RW)
     deleteDomain(id: Int!): DeletePayload! @access(scope: DOMAINS, kind: RW)
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index d82d48b..bd8d6ed 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -39,7 +39,6 @@ import (
 	"golang.org/x/image/draw"
 	"golang.org/x/net/idna"
 	"netlandish.com/x/gobwebs"
-	auditlog "netlandish.com/x/gobwebs-auditlog"
 	oauth2 "netlandish.com/x/gobwebs-oauth2"
 	gaccounts "netlandish.com/x/gobwebs/accounts"
 	gcore "netlandish.com/x/gobwebs/core"
@@ -89,6 +88,7 @@ func (r *mutationResolver) AddOrganization(ctx context.Context, input model.Orga
 		return nil, valid.ErrAuthorization
 	}
 	user := tokenUser.User.(*models.User)
+	c := server.EchoForContext(ctx)
 	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
 	lt := localizer.GetLocalizer(lang)
 
@@ -182,6 +182,20 @@ func (r *mutationResolver) AddOrganization(ctx context.Context, input model.Orga
 		return nil, err
 	}
 
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_ORG_ADDED,
+		fmt.Sprintf("Added organization '%s' (%d)", org.Slug, org.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	return org, nil
 }
 
@@ -352,6 +366,21 @@ func (r *mutationResolver) AddLink(ctx context.Context, input *model.LinkInput)
 		}
 	}
 
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	err = models.RecordAuditLog(
+		ctx,
+		userID,
+		c.RealIP(),
+		models.LOG_BOOKMARK_ADDED,
+		fmt.Sprintf("Added bookmark '%s'", OrgLink.Hash),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	return OrgLink, nil
 }
 
@@ -545,6 +574,28 @@ func (r *mutationResolver) UpdateLink(ctx context.Context, input *model.UpdateLi
 			return nil, err
 		}
 	}
+
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	ltype := models.LOG_BOOKMARK_UPDATED
+	ldet := "bookmark"
+	if orgLink.Type == models.NoteType {
+		ltype = models.LOG_NOTE_UPDATED
+		ldet = "note"
+	}
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		ltype,
+		fmt.Sprintf("Updated %s '%s'", ldet, orgLink.Hash),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	return orgLink, nil
 }
 
@@ -580,28 +631,28 @@ func (r *mutationResolver) DeleteLink(ctx context.Context, hash string) (*model.
 	}
 	link := orgLinks[0]
 
-	// If the user is not the link creator, verify org write permissions
-	if link.UserID != int(user.ID) {
-		opts = &database.FilterOptions{
-			Filter: sq.And{
-				sq.Expr("o.id = ?", link.OrgID),
-				sq.Expr("o.is_active = true"),
-			},
-			Limit: 1,
-		}
+	opts = &database.FilterOptions{
+		Filter: sq.And{
+			sq.Expr("o.id = ?", link.OrgID),
+			sq.Expr("o.is_active = true"),
+		},
+		Limit: 1,
+	}
 
-		orgs, err := models.GetOrganizations(ctx, opts)
-		if err != nil {
-			return nil, err
-		}
+	orgs, err := models.GetOrganizations(ctx, opts)
+	if err != nil {
+		return nil, err
+	}
 
-		if len(orgs) == 0 {
-			validator.Error("%s", lt.Translate("This user is not allowed to perform this action")).
-				WithCode(valid.ErrNotFoundCode)
-			return nil, nil
-		}
+	if len(orgs) == 0 {
+		validator.Error("%s", lt.Translate("This user is not allowed to perform this action")).
+			WithCode(valid.ErrNotFoundCode)
+		return nil, nil
+	}
+	org := orgs[0]
 
-		org := orgs[0]
+	// If the user is not the link creator, verify org write permissions
+	if link.UserID != int(user.ID) {
 		if !org.CanWrite(ctx, user) {
 			validator.Error("%s", lt.Translate("This user is not allowed to perform this action")).
 				WithCode(valid.ErrNotFoundCode)
@@ -628,6 +679,28 @@ func (r *mutationResolver) DeleteLink(ctx context.Context, hash string) (*model.
 			return nil, err
 		}
 	}
+
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	ltype := models.LOG_BOOKMARK_DELETED
+	ldet := "bookmark"
+	if link.Type == models.NoteType {
+		ltype = models.LOG_NOTE_DELETED
+		ldet = "note"
+	}
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		ltype,
+		fmt.Sprintf("Deleted %s '%s'", ldet, link.Hash),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	deletePayload.Success = true
 	deletePayload.ObjectID = deletedID
 	return deletePayload, nil
@@ -929,6 +1002,22 @@ func (r *mutationResolver) AddMember(ctx context.Context, input *model.MemberInp
 	if err != nil {
 		return nil, err
 	}
+
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	mdata["user_id"] = user.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(currentUser.ID),
+		c.RealIP(),
+		models.LOG_MEMBER_ADDED,
+		fmt.Sprintf("Added member %s", user.Email),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	addMemberPayload.Success = true
 	return addMemberPayload, nil
 }
@@ -977,8 +1066,9 @@ func (r *mutationResolver) DeleteMember(ctx context.Context, orgSlug string, ema
 		return nil, nil
 	}
 
+	email = strings.ToLower(email)
 	opts := &database.FilterOptions{
-		Filter: sq.Eq{"u.email": strings.ToLower(email)},
+		Filter: sq.Eq{"u.email": email},
 		Limit:  1,
 	}
 
@@ -1015,6 +1105,22 @@ func (r *mutationResolver) DeleteMember(ctx context.Context, orgSlug string, ema
 				return nil, err
 			}
 		}
+
+		mdata := make(map[string]any)
+		mdata["org_id"] = org.ID
+		mdata["user_id"] = duser.ID
+		err = models.RecordAuditLog(
+			ctx,
+			int(user.ID),
+			c.RealIP(),
+			models.LOG_MEMBER_REMOVED,
+			fmt.Sprintf("Removed member %s", email),
+			mdata,
+		)
+		if err != nil {
+			return nil, err
+		}
+
 		addMemberPayload.Success = true
 		addMemberPayload.Message = lt.Translate("The member was removed successfully")
 	}
@@ -1126,6 +1232,22 @@ func (r *mutationResolver) ConfirmMember(ctx context.Context, key string) (*mode
 	if err != nil {
 		return nil, err
 	}
+
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_MEMBER_CONFIRMED,
+		fmt.Sprintf("Confirmed member %s", user.Email),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	addMemberPayload := &model.AddMemberPayload{
 		Success: true,
 		Message: lt.Translate("The user was added successfully"),
@@ -1585,6 +1707,21 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, input *model.Profi
 		}
 	}
 
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = personalOrg.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_PROFILE_UPDATED,
+		fmt.Sprintf("Updated profile"),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	return user, nil
 }
 
@@ -1764,6 +1901,21 @@ func (r *mutationResolver) UpdateOrganization(ctx context.Context, input *model.
 		return nil, err
 	}
 
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_ORG_UPDATED,
+		fmt.Sprintf("Updated organization '%s' (%d)", org.Slug, org.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	return org, nil
 }
 
@@ -1901,6 +2053,23 @@ func (r *mutationResolver) AddDomain(ctx context.Context, input model.DomainInpu
 			return nil, err
 		}
 	}
+
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	mdata["domain_id"] = domain.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_DOMAIN_ADDED,
+		fmt.Sprintf("Added domain '%s'", domain.LookupName),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	return domain, nil
 }
 
@@ -1969,6 +2138,22 @@ func (r *mutationResolver) DeleteDomain(ctx context.Context, id int) (*model.Del
 		return nil, err
 	}
 
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = domain.OrgID
+	mdata["domain_id"] = domain.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_DOMAIN_DELETED,
+		fmt.Sprintf("Deleted domain '%s'", domain.LookupName),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	deletePayload.Success = true
 	deletePayload.ObjectID = fmt.Sprint(id)
 	return deletePayload, nil
@@ -2170,14 +2355,16 @@ func (r *mutationResolver) AddLinkShort(ctx context.Context, input *model.LinkSh
 		}
 	}
 
-	alog := auditlog.New(
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	err = models.RecordAuditLog(
+		ctx,
 		int(user.ID),
 		c.RealIP(),
 		models.LOG_SHORT_ADDED,
-		fmt.Sprintf("Added short link '%s'", linkShort.ShortCode),
+		fmt.Sprintf("Added short link '%s' (%d)", linkShort.ShortCode, linkShort.ID),
+		mdata,
 	)
-	alog.AddMetadata("org_id", org.ID)
-	err = alog.Store(ctx)
 	if err != nil {
 		return nil, err
 	}
@@ -2192,6 +2379,7 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd
 		return nil, valid.ErrAuthorization
 	}
 	user := tokenUser.User.(*models.User)
+	c := server.EchoForContext(ctx)
 	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
 	lt := localizer.GetLocalizer(lang)
 
@@ -2362,6 +2550,20 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd
 		}
 	}
 
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_SHORT_UPDATED,
+		fmt.Sprintf("Updated short link '%s' (%d)", linkShort.ShortCode, linkShort.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	return linkShort, nil
 }
 
@@ -2372,6 +2574,7 @@ func (r *mutationResolver) DeleteLinkShort(ctx context.Context, id int) (*model.
 		return nil, valid.ErrAuthorization
 	}
 	user := tokenUser.User.(*models.User)
+	c := server.EchoForContext(ctx)
 	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
 	lt := localizer.GetLocalizer(lang)
 	deletePayload := &model.DeletePayload{
@@ -2413,6 +2616,21 @@ func (r *mutationResolver) DeleteLinkShort(ctx context.Context, id int) (*model.
 	if err != nil {
 		return nil, err
 	}
+
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_SHORT_DELETED,
+		fmt.Sprintf("Deleted short link '%s' (%d)", link.ShortCode, link.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	deletePayload.Success = true
 	deletePayload.ObjectID = deletedID
 	return deletePayload, nil
@@ -2714,6 +2932,22 @@ func (r *mutationResolver) AddListing(ctx context.Context, input *model.AddListi
 		}
 	}
 
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	mdata["list_id"] = listing.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_LIST_ADDED,
+		fmt.Sprintf("Added link listing '%s' (%d)", listing.Slug, listing.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	return listing, nil
 }
 
@@ -2799,6 +3033,24 @@ func (r *mutationResolver) AddListingLink(ctx context.Context, input *model.AddL
 	if err != nil {
 		return nil, err
 	}
+
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	mdata["list_id"] = listing.ID
+	mdata["list_link_id"] = listingLink.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_LIST_LINK_ADDED,
+		fmt.Sprintf("Added listing entry '%s' (%d)", listingLink.Title, listingLink.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	return listingLink, nil
 }
 
@@ -3126,6 +3378,23 @@ func (r *mutationResolver) UpdateListing(ctx context.Context, input *model.Updat
 			return nil, err
 		}
 	}
+
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	mdata["list_id"] = listing.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_LIST_UPDATED,
+		fmt.Sprintf("Updated link listing '%s' (%d)", listing.Slug, listing.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	return listing, nil
 }
 
@@ -3199,6 +3468,24 @@ func (r *mutationResolver) UpdateListingLink(ctx context.Context, input *model.U
 	if err != nil {
 		return nil, err
 	}
+
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	mdata["list_id"] = listingLink.ListingID
+	mdata["list_link_id"] = listingLink.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_LIST_LINK_UPDATED,
+		fmt.Sprintf("Updated listing entry '%s' (%d)", listingLink.Title, listingLink.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	return listingLink, nil
 }
 
@@ -3251,6 +3538,23 @@ func (r *mutationResolver) DeleteListing(ctx context.Context, id int) (*model.De
 	if err != nil {
 		return nil, err
 	}
+
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	mdata["list_id"] = listing.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_LIST_DELETED,
+		fmt.Sprintf("Deleted link listing '%s' (%d)", listing.Slug, listing.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	deletePayload.Success = true
 	deletePayload.ObjectID = deletedID
 	return deletePayload, nil
@@ -3305,6 +3609,24 @@ func (r *mutationResolver) DeleteListingLink(ctx context.Context, id int) (*mode
 	if err != nil {
 		return nil, err
 	}
+
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	mdata["list_id"] = listingLink.ListingID
+	mdata["list_link_id"] = listingLink.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_LIST_LINK_DELETED,
+		fmt.Sprintf("Updated listing entry '%s' (%d)", listingLink.Title, listingLink.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	deletePayload.Success = true
 	deletePayload.ObjectID = deletedID
 	return deletePayload, nil
@@ -3580,6 +3902,23 @@ func (r *mutationResolver) AddQRCode(ctx context.Context, input model.AddQRCodeI
 			return nil, err
 		}
 	}
+
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	mdata["qrcode_id"] = qr.ID
+	mdata["qrcode_type"] = qr.CodeType
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_QRCODE_ADDED,
+		fmt.Sprintf("Added QR code '%s', type: %s (%d)", qr.Title, qr.CodeType, qr.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	return qr, nil
 }
 
@@ -3632,6 +3971,24 @@ func (r *mutationResolver) DeleteQRCode(ctx context.Context, id int) (*model.Del
 	if err != nil {
 		return nil, err
 	}
+
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	mdata["qrcode_id"] = qrCode.ID
+	mdata["qrcode_type"] = qrCode.CodeType
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_QRCODE_DELETED,
+		fmt.Sprintf("Deleted QR code '%s', type: %s (%d)", qrCode.Title, qrCode.CodeType, qrCode.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	deletePayload.Success = true
 	deletePayload.ObjectID = deletedID
 	return deletePayload, nil
@@ -3677,21 +4034,31 @@ func (r *mutationResolver) Follow(ctx context.Context, orgSlug string) (*model.F
 		return nil, err
 	}
 
-	if len(follows) > 0 {
-		validator.Error("%s", lt.Translate("This user already follows this org")).
-			WithField("orgSlug").
-			WithCode(valid.ErrValidationCode)
-		return nil, nil
-	}
+	if len(follows) == 0 {
+		follow := &models.Follower{
+			UserID: int(user.ID),
+			OrgID:  org.ID,
+		}
 
-	follow := &models.Follower{
-		UserID: int(user.ID),
-		OrgID:  org.ID,
-	}
+		err = follow.Store(ctx)
+		if err != nil {
+			return nil, err
+		}
 
-	err = follow.Store(ctx)
-	if err != nil {
-		return nil, err
+		c := server.EchoForContext(ctx)
+		mdata := make(map[string]any)
+		mdata["org_id"] = org.ID
+		err = models.RecordAuditLog(
+			ctx,
+			int(user.ID),
+			c.RealIP(),
+			models.LOG_ORG_FOLLOW,
+			fmt.Sprintf("Followed organization '%s' (%d)", org.Slug, org.ID),
+			mdata,
+		)
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	payload := &model.FollowPayload{
@@ -3754,6 +4121,22 @@ func (r *mutationResolver) Unfollow(ctx context.Context, orgSlug string) (*model
 	if err != nil {
 		return nil, err
 	}
+
+	c := server.EchoForContext(ctx)
+	mdata := make(map[string]any)
+	mdata["org_id"] = org.ID
+	err = models.RecordAuditLog(
+		ctx,
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_ORG_UNFOLLOW,
+		fmt.Sprintf("Unfollowed organization '%s' (%d)", org.Slug, org.ID),
+		mdata,
+	)
+	if err != nil {
+		return nil, err
+	}
+
 	payload := &model.FollowPayload{
 		Success: true,
 		Message: lt.Translate("User unfollows %s", orgSlug),
diff --git a/models/audit_log.go b/models/audit_log.go
new file mode 100644
index 0000000..5e5f268
--- /dev/null
+++ b/models/audit_log.go
@@ -0,0 +1,65 @@
+package models
+
+import (
+	"context"
+
+	auditlog "netlandish.com/x/gobwebs-auditlog"
+)
+
+// Event types for audit log entries
+const (
+	LOG_ACCT_LOGIN           = "account_login"
+	LOG_ACCT_EMAIL_UPDATE    = "account_email_update"
+	LOG_ACCT_EMAIL_CONF      = "account_email_confirmation"
+	LOG_ACCT_PASSWORD_CHANGE = "account_password_change"
+	LOG_ACCT_PASSWORD_RESET  = "account_password_reset"
+
+	LOG_PROFILE_UPDATED = "profile_updated"
+
+	LOG_ORG_ADDED   = "organization_added"
+	LOG_ORG_UPDATED = "organization_updated"
+
+	LOG_BOOKMARK_ADDED   = "bookmark_added"
+	LOG_BOOKMARK_UPDATED = "bookmark_added"
+	LOG_BOOKMARK_DELETED = "bookmark_added"
+
+	LOG_NOTE_ADDED   = "note_added"
+	LOG_NOTE_UPDATED = "note_added"
+	LOG_NOTE_DELETED = "note_added"
+
+	LOG_SHORT_ADDED   = "short_added"
+	LOG_SHORT_UPDATED = "short_updated"
+	LOG_SHORT_DELETED = "short_deleted"
+
+	LOG_LIST_ADDED   = "list_added"
+	LOG_LIST_UPDATED = "list_updated"
+	LOG_LIST_DELETED = "list_deleted"
+
+	LOG_LIST_LINK_ADDED   = "list_link_added"
+	LOG_LIST_LINK_UPDATED = "list_link_updated"
+	LOG_LIST_LINK_DELETED = "list_link_deleted"
+
+	LOG_MEMBER_ADDED     = "member_added"
+	LOG_MEMBER_CONFIRMED = "member_confirmed"
+	LOG_MEMBER_REMOVED   = "member_removed"
+
+	LOG_DOMAIN_ADDED   = "domain_added"
+	LOG_DOMAIN_DELETED = "domain_deleted"
+
+	LOG_QRCODE_ADDED   = "qrcode_added"
+	LOG_QRCODE_DELETED = "qrcode_deleted"
+
+	LOG_ORG_FOLLOW   = "org_follow"
+	LOG_ORG_UNFOLLOW = "org_unfollow"
+)
+
+func RecordAuditLog(ctx context.Context, userID int,
+	ipAddress, eventType, details string, metadata map[string]any) error {
+	alog := auditlog.New(userID, ipAddress, eventType, details)
+	if metadata != nil {
+		for k, v := range metadata {
+			alog.AddMetadata(k, v)
+		}
+	}
+	return alog.Store(ctx)
+}
diff --git a/models/models.go b/models/models.go
index 5dff73d..6728306 100644
--- a/models/models.go
+++ b/models/models.go
@@ -19,11 +19,6 @@ const (
 	PRIVATERSSFEEDCONF         = 300
 )
 
-// Event types for audit log entries
-const (
-	LOG_SHORT_ADDED = "short_added"
-)
-
 type AnalyticsData map[string]int
 
 // User ...
-- 
2.47.2

