Subject: [PATCH links] Adding audit logs for various data modification functions.

From: Peter Sanchez <[email-masked]>
Date: Fri, 21 Feb 2025 17:11:39 -0600

---
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